Compare commits

..

No commits in common. "21796d79b698a23ca1fc0e1787d2879e7764790f" and "7717bf13a17ae357950ec80b7ba81764e9673ffe" have entirely different histories.

17 changed files with 164 additions and 1367 deletions

View File

@ -19,94 +19,7 @@ using static VideoConcat.Models.VideoModel;
namespace VideoConcat.Models namespace VideoConcat.Models
{ {
/// <summary>
/// 抽帧处理方式
/// </summary>
public enum ExtractFrameMode
{
DeleteFrame = 0, // 删除帧
AddTransparentImage = 1 // 添加透明图
}
/// <summary>
/// 处理任务信息(用于表格显示)
/// </summary>
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(); }
}
/// <summary>
/// 完整文件名用于ToolTip显示
/// </summary>
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 public class ExtractWindowModel : INotifyPropertyChanged
{ {
@ -120,8 +33,6 @@ namespace VideoConcat.Models
private string[] _videos = []; private string[] _videos = [];
private int _extractCount = 1; private int _extractCount = 1;
private Dispatcher _dispatcher; private Dispatcher _dispatcher;
private ExtractFrameMode _extractFrameMode = ExtractFrameMode.DeleteFrame;
private ObservableCollection<ExtractTaskItem> _taskItems = new();
public string[] videos public string[] videos
{ {
@ -242,72 +153,6 @@ namespace VideoConcat.Models
} }
} }
public ExtractFrameMode ExtractFrameMode
{
get => _extractFrameMode;
set
{
_extractFrameMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsDeleteFrameMode));
OnPropertyChanged(nameof(IsAddTransparentImageMode));
}
}
/// <summary>
/// 是否选择删除帧模式用于RadioButton绑定
/// </summary>
public bool IsDeleteFrameMode
{
get => _extractFrameMode == ExtractFrameMode.DeleteFrame;
set
{
if (value && _extractFrameMode != ExtractFrameMode.DeleteFrame)
{
ExtractFrameMode = ExtractFrameMode.DeleteFrame;
}
}
}
/// <summary>
/// 是否选择添加透明图模式用于RadioButton绑定
/// </summary>
public bool IsAddTransparentImageMode
{
get => _extractFrameMode == ExtractFrameMode.AddTransparentImage;
set
{
if (value && _extractFrameMode != ExtractFrameMode.AddTransparentImage)
{
ExtractFrameMode = ExtractFrameMode.AddTransparentImage;
}
}
}
/// <summary>
/// 抽帧按钮的文本(固定显示"操作"
/// </summary>
public string ExtractButtonText
{
get
{
return "操作";
}
}
/// <summary>
/// 任务列表(用于表格显示)
/// </summary>
public ObservableCollection<ExtractTaskItem> TaskItems
{
get => _taskItems;
set
{
_taskItems = value;
OnPropertyChanged();
}
}
public void SetCanStart() public void SetCanStart()
{ {
// 有视频文件且生成个数大于0时可以抽帧 // 有视频文件且生成个数大于0时可以抽帧

View File

@ -289,15 +289,13 @@ namespace VideoConcat.Services.Video
LogUtils.Info($"SubVideo输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒"); LogUtils.Info($"SubVideo输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒");
// 使用输入时的 -ss 参数(在输入参数中),这样可以更精确地定位,减少重新编码 // 使用 -ss 和 -t 参数进行裁剪
// 使用 -c copy 确保不重新编码
bool result = FFMpegArguments bool result = FFMpegArguments
.FromFileInput(inputPath, true, options => options .FromFileInput(inputPath, true, options => options
.WithCustomArgument($"-ss {startSec:F3}")) // 在输入时指定起始时间,更精确 .Seek(TimeSpan.FromSeconds(startSec))
.WithCustomArgument($"-t {duration}")) // 指定时长而不是结束时间
.OutputToFile(outputPath, true, options => options .OutputToFile(outputPath, true, options => options
.WithCustomArgument($"-t {duration:F3}") // 指定时长 .CopyChannel()) // 复制流,不重新编码
.WithVideoCodec("copy") // 明确指定复制视频流
.WithAudioCodec("copy")) // 明确指定复制音频流
.ProcessSynchronously(); .ProcessSynchronously();
// 验证输出文件是否存在 // 验证输出文件是否存在
@ -464,522 +462,5 @@ namespace VideoConcat.Services.Video
) )
.ProcessSynchronously(); .ProcessSynchronously();
} }
/// <summary>
/// 随机删除一个非关键帧
/// </summary>
/// <param name="inputPath">输入文件路径</param>
/// <param name="outputPath">输出文件路径</param>
/// <param name="index">当前处理的序号(用于确保每次删除位置不同)</param>
/// <returns>操作是否成功</returns>
public static async Task<bool> 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<string> 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 { }
}
}
/// <summary>
/// 在随机位置添加一个肉眼无法感知的小像素透明图
/// </summary>
/// <param name="inputPath">输入文件路径</param>
/// <param name="outputPath">输出文件路径</param>
/// <returns>操作是否成功</returns>
public static async Task<bool> 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到宽度-10到高度-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 { }
}
}
} }
} }

View File

@ -1,4 +1,4 @@
using FFMpegCore; using FFMpegCore;
using FFMpegCore.Enums; using FFMpegCore.Enums;
using Microsoft.Expression.Drawing.Core; using Microsoft.Expression.Drawing.Core;
using System.IO; using System.IO;
@ -73,14 +73,9 @@ namespace VideoConcat.ViewModels
return; return;
} }
string modeText = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
DateTime startTime = DateTime.Now;
ExtractWindowModel.Dispatcher.Invoke(() => ExtractWindowModel.Dispatcher.Invoke(() =>
{ {
ExtractWindowModel.HelpInfo = $"处理方案:{modeText} | 视频数量:{ExtractWindowModel.videos.Length} 个 | 每个生成:{extractCount} 个 | 总任务数:{ExtractWindowModel.videos.Length * extractCount} 个 | 开始时间:{startTime:yyyy-MM-dd HH:mm:ss}"; ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频...";
// 清空任务列表,以便重新开始
ExtractWindowModel.TaskItems.Clear();
ExtractWindowModel.IsStart = true; ExtractWindowModel.IsStart = true;
ExtractWindowModel.IsCanOperate = false; ExtractWindowModel.IsCanOperate = false;
}); });
@ -91,11 +86,6 @@ namespace VideoConcat.ViewModels
int totalTasks = ExtractWindowModel.videos.Length * extractCount; int totalTasks = ExtractWindowModel.videos.Length * extractCount;
int completedTasks = 0; int completedTasks = 0;
System.Collections.Concurrent.ConcurrentBag<string> errorMessages = new(); // 收集错误信息 System.Collections.Concurrent.ConcurrentBag<string> errorMessages = new(); // 收集错误信息
System.Collections.Concurrent.ConcurrentBag<string> successMessages = new(); // 收集成功信息
long totalOriginalSize = 0; // 原始文件总大小
long totalOutputSize = 0; // 输出文件总大小
int successCount = 0; // 成功数量
int skipCount = 0; // 跳过数量(已存在)
// 对每个视频生成指定数量的抽帧视频 // 对每个视频生成指定数量的抽帧视频
foreach (var video in ExtractWindowModel.videos) foreach (var video in ExtractWindowModel.videos)
@ -125,10 +115,8 @@ namespace VideoConcat.ViewModels
string originalFileName = Path.GetFileNameWithoutExtension(currentVideo); string originalFileName = Path.GetFileNameWithoutExtension(currentVideo);
string extension = Path.GetExtension(currentVideo); string extension = Path.GetExtension(currentVideo);
// 生成唯一的文件名:原始名称+方案号+生成日期+序号 // 生成唯一的文件名原文件名_序号.扩展名
string modeSuffix = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2"; string _tmpFileName = $"{originalFileName}_{currentIndex:D4}{extension}";
string dateStr = DateTime.Now.ToString("yyyyMMdd");
string _tmpFileName = $"{originalFileName}_{modeSuffix}_{dateStr}_{currentIndex:D4}{extension}";
string outPath = Path.Combine(_tmpPath, "out"); string outPath = Path.Combine(_tmpPath, "out");
LogUtils.Info($"准备创建输出目录:{outPath}"); LogUtils.Info($"准备创建输出目录:{outPath}");
@ -155,83 +143,35 @@ namespace VideoConcat.ViewModels
string outputPath = Path.Combine(outPath, _tmpFileName); string outputPath = Path.Combine(outPath, _tmpFileName);
LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}"); 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)) if (File.Exists(outputPath))
{ {
LogUtils.Info($"文件已存在,跳过:{outputPath}"); LogUtils.Info($"文件已存在,跳过:{outputPath}");
Interlocked.Increment(ref skipCount);
Interlocked.Increment(ref completedTasks); Interlocked.Increment(ref completedTasks);
currentCompleted = completedTasks;
progressPercent = currentCompleted * 100.0 / totalTasks;
ExtractWindowModel.Dispatcher.Invoke(() => ExtractWindowModel.Dispatcher.Invoke(() =>
{ {
var taskItem = new Models.ExtractTaskItem ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "跳过",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
}); });
return; return;
} }
// 先检查视频时长和获取视频信息 // 先检查视频时长
IMediaAnalysis? mediaInfo = null;
double totalDuration = 0;
try try
{ {
mediaInfo = await FFProbe.AnalyseAsync(currentVideo); var mediaInfo = await FFProbe.AnalyseAsync(currentVideo);
totalDuration = mediaInfo.Duration.TotalSeconds; double totalDuration = mediaInfo.Duration.TotalSeconds;
if (totalDuration < 20 && ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame) if (totalDuration < 20)
{ {
string errorMsg = $"视频时长太短:{currentVideo}{totalDuration:F2}秒需要至少20秒"; string videoName = Path.GetFileName(currentVideo);
string errorMsg = $"视频时长太短:{videoName}{totalDuration:F2}秒无法抽帧需要至少20秒";
LogUtils.Error(errorMsg); LogUtils.Error(errorMsg);
errorMessages.Add(errorMsg); errorMessages.Add(errorMsg);
Interlocked.Increment(ref completedTasks); Interlocked.Increment(ref completedTasks);
currentCompleted = completedTasks;
progressPercent = currentCompleted * 100.0 / totalTasks;
ExtractWindowModel.Dispatcher.Invoke(() => ExtractWindowModel.Dispatcher.Invoke(() =>
{ {
var taskItem = new Models.ExtractTaskItem ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "失败",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
}); });
return; return;
} }
@ -239,156 +179,46 @@ namespace VideoConcat.ViewModels
catch (Exception ex) catch (Exception ex)
{ {
LogUtils.Error($"检查视频时长失败:{currentVideo}", 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)) if (File.Exists(outputPath))
{ {
FileInfo outputFileInfo = new FileInfo(outputPath); FileInfo fileInfo = new FileInfo(outputPath);
long outputSize = outputFileInfo.Length; LogUtils.Info($"抽帧成功:{currentVideo} -> {outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB");
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($"{currentVideo} (第{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) else if (success)
{ {
LogUtils.Warn($"处理返回成功但文件不存在:{outputPath}"); LogUtils.Warn($"抽帧返回成功但文件不存在:{outputPath}");
string errorMsg = $"{currentVideo} (第{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 else
{ {
string errorMsg = $"{currentVideo} (第{currentIndex}个) - 处理失败"; string videoName = Path.GetFileName(currentVideo);
string errorMsg = $"抽帧失败:{videoName}";
LogUtils.Error($"{errorMsg} -> {outputPath}"); LogUtils.Error($"{errorMsg} -> {outputPath}");
errorMessages.Add(errorMsg); 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) catch (Exception ex)
{ {
// 在异常处理中重新获取文件名等信息 string videoName = Path.GetFileName(currentVideo);
string originalFileNameForError = Path.GetFileNameWithoutExtension(currentVideo); string errorMsg = $"抽帧异常:{videoName} (第{currentIndex}个) - {ex.Message}";
string errorMsg = $"抽帧异常:{currentVideo} (第{currentIndex}个) - {ex.Message}";
LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex); LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex);
errorMessages.Add(errorMsg); errorMessages.Add(errorMsg);
Interlocked.Increment(ref completedTasks); 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);
string modeSuffixForError2 = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
string dateStrForError2 = DateTime.Now.ToString("yyyyMMdd");
ExtractWindowModel.Dispatcher.Invoke(() => ExtractWindowModel.Dispatcher.Invoke(() =>
{ {
var taskItem = new Models.ExtractTaskItem ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
{
Index = currentIndex.ToString(),
FileName = displayFileNameForError,
FullFileName = $"{originalFileNameForError}_{modeSuffixForError2}_{dateStrForError2}_{currentIndex:D4}{Path.GetExtension(currentVideo)}",
Status = "异常",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercentForError:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
}); });
} }
finally finally
@ -402,9 +232,6 @@ namespace VideoConcat.ViewModels
await Task.WhenAll(_tasks); await Task.WhenAll(_tasks);
// 计算总耗时
TimeSpan totalDuration = DateTime.Now - startTime;
// 统计实际生成的文件数量 // 统计实际生成的文件数量
int actualFileCount = 0; int actualFileCount = 0;
string outputDir = ""; string outputDir = "";
@ -421,40 +248,25 @@ namespace VideoConcat.ViewModels
} }
} }
// 构建最终详细信息 // 构建最终信息
double totalOriginalMB = totalOriginalSize / 1024.0 / 1024.0; string summaryInfo = $"全部完成! 共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件\n输出目录{outputDir}";
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.Dispatcher.Invoke(() =>
{ {
ExtractWindowModel.HelpInfo += $"\n{summaryInfo}";
// 如果有错误信息,显示汇总 // 如果有错误信息,显示汇总
if (errorMessages.Count > 0) if (errorMessages.Count > 0)
{ {
ExtractWindowModel.HelpInfo += $"\n错误信息共{errorMessages.Count}个):"; string errorSummary = string.Join("\n", errorMessages);
foreach (var error in errorMessages) ExtractWindowModel.HelpInfo = $"{summaryInfo}\n\n错误信息共{errorMessages.Count}个):\n{errorSummary}";
{ }
ExtractWindowModel.HelpInfo += $"\n • {error}"; else
} {
ExtractWindowModel.HelpInfo = summaryInfo;
} }
ExtractWindowModel.IsStart = false; ExtractWindowModel.IsStart = false;
ExtractWindowModel.IsCanOperate = true; ExtractWindowModel.IsCanOperate = true;
}); });
LogUtils.Info($"处理完成,成功={successCount}, 失败={errorMessages.Count}, 跳过={skipCount}, 总耗时={totalDuration.TotalSeconds:F1}秒"); LogUtils.Info($"抽帧处理完成,共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件,输出目录:{outputDir}");
}); });
} }
}, },

View File

@ -23,65 +23,23 @@
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="40"></RowDefinition>
<RowDefinition Height="*"></RowDefinition> <RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border> <Border>
<WrapPanel Orientation="Horizontal" VerticalAlignment="Center"> <WrapPanel>
<Label VerticalAlignment="Center" Width="100" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">视频文件夹:</Label> <Label VerticalAlignment="Center" Width="100" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">视频文件夹:</Label>
<TextBox Grid.Column="1" Width="500" Name="FoldPath" Text="{Binding ExtractWindowModel.FolderPath,Mode=TwoWay}" IsReadOnly="True" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" materialDesign:HintAssist.Hint="选择包含需要处理的视频文件夹"/> <TextBox Grid.Column="1" Width="500" Name="FoldPath" Text="{Binding ExtractWindowModel.FolderPath,Mode=TwoWay}" IsReadOnly="True" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" materialDesign:HintAssist.Hint="选择包含需要处理的视频文件夹"/>
<Button Grid.Column="2" Width="80" Content="选择" FontSize="16" Command="{Binding ExtractWindowModel.BtnOpenFolderCommand}" Background="#40568D" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="选择含有视频的主文件夹" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/> <Button Grid.Column="2" Width="80" Content="选择" FontSize="16" Command="{Binding ExtractWindowModel.BtnOpenFolderCommand}" Background="#40568D" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="选择含有视频的主文件夹" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/>
<Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">生成个数:</Label> <Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">生成个数:</Label>
<TextBox Width="100" Text="{Binding ExtractWindowModel.ExtractCount,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" ToolTip="输入要生成的抽帧视频个数" materialDesign:HintAssist.Hint="个数" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/> <TextBox Width="100" Text="{Binding ExtractWindowModel.ExtractCount,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" ToolTip="输入要生成的抽帧视频个数" materialDesign:HintAssist.Hint="个数" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/>
<Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">处理方式:</Label> <Button Grid.Column="3" Width="80" Content="抽帧" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoConcatCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始处理视频" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanExtractFrame}"/>
<RadioButton Content="方案1" IsChecked="{Binding ExtractWindowModel.IsDeleteFrameMode,Mode=TwoWay}" Margin="5,2" FontSize="14" VerticalAlignment="Center" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}" GroupName="ExtractMode" ToolTip="随机删除一个非关键帧"/> <Button Grid.Column="4" Width="80" Content="修改" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoModifyCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始修改视频元数据" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanModify}"/>
<RadioButton Content="方案2" IsChecked="{Binding ExtractWindowModel.IsAddTransparentImageMode,Mode=TwoWay}" Margin="5,2" FontSize="14" VerticalAlignment="Center" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}" GroupName="ExtractMode" ToolTip="在随机位置添加一个肉眼无法感知的小像素透明图"/>
<Button Grid.Column="3" Width="120" Content="{Binding ExtractWindowModel.ExtractButtonText}" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoConcatCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始处理视频" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanExtractFrame}"/>
<Button Grid.Column="4" Width="120" Content="修改meta" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoModifyCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始修改视频元数据" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanModify}"/>
</WrapPanel> </WrapPanel>
</Border> </Border>
<Border Grid.Row="1"> <Border Grid.Row="1">
<Grid> <TextBlock Text="{Binding ExtractWindowModel.HelpInfo,Mode=TwoWay}"></TextBlock>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding ExtractWindowModel.HelpInfo,Mode=TwoWay}" Margin="5" TextWrapping="Wrap"/>
<DataGrid Grid.Row="1"
ItemsSource="{Binding ExtractWindowModel.TaskItems}"
AutoGenerateColumns="False"
IsReadOnly="True"
HeadersVisibility="Column"
GridLinesVisibility="All"
AlternatingRowBackground="LightGray"
Margin="5"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="50"/>
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="4*" MinWidth="350">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="TextTrimming" Value="None"/>
<Setter Property="ToolTip" Value="{Binding FullFileName}"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="2,0"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="70"/>
<DataGridTextColumn Header="原始大小" Binding="{Binding OriginalSize}" Width="90"/>
<DataGridTextColumn Header="输出大小" Binding="{Binding OutputSize}" Width="90"/>
<DataGridTextColumn Header="大小变化" Binding="{Binding SizeChange}" Width="90"/>
<DataGridTextColumn Header="耗时" Binding="{Binding Duration}" Width="70"/>
<DataGridTextColumn Header="进度" Binding="{Binding Progress}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border> </Border>
</Grid> </Grid>
</Border> </Border>

View File

@ -13,8 +13,7 @@ namespace VideoConcat.Views
/// </summary> /// </summary>
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private VideoWindow? _videoWindow;
private ExtractWindow? _extractWindow;
public MainWindow() public MainWindow()
{ {
@ -30,10 +29,10 @@ namespace VideoConcat.Views
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 2. 实例化已有视图(或获取已存在的视图实例) // 2. 实例化已有视图(或获取已存在的视图实例)
_videoWindow = new VideoWindow(); // 这里是已有视图的实例 var existingView = new VideoWindow(); // 这里是已有视图的实例
// 3. 将视图添加到指定列中例如第1列索引为1 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _videoWindow); AddViewToColumn(mainGrid, existingView);
} }
/// <summary> /// <summary>
@ -75,60 +74,24 @@ namespace VideoConcat.Views
{ {
if (radioButton.Name == "extract") if (radioButton.Name == "extract")
{ {
// 切换到extract tab检查是否有正在进行的处理
if (_extractWindow != null)
{
var viewModel = _extractWindow.DataContext as ViewModels.ExtractWindowViewModel;
if (viewModel != null && viewModel.ExtractWindowModel.IsStart)
{
// 有正在进行的处理,允许切换,因为用户可能想查看进度
// 不做任何阻止操作
}
}
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 2. 实例化已有视图(或获取已存在的视图实例)
// 如果已存在实例,重用;否则创建新实例 var existingView = new ExtractWindow(); // 这里是已有视图的实例
if (_extractWindow == null)
{
_extractWindow = new ExtractWindow();
}
// 将视图添加到Grid中 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _extractWindow); AddViewToColumn(mainGrid, existingView);
} }
if (radioButton.Name == "video") if (radioButton.Name == "video")
{ {
// 从extract切换到video时检查是否有正在进行的处理 // 1. 为目标Grid创建列定义
if (_extractWindow != null)
{
var viewModel = _extractWindow.DataContext as ViewModels.ExtractWindowViewModel;
if (viewModel != null && viewModel.ExtractWindowModel.IsStart)
{
// 有正在进行的处理,显示提示并阻止切换
System.Windows.MessageBox.Show("当前有正在进行的处理任务,请等待处理完成后再切换!", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
// 恢复extract tab的选中状态
radioButton.IsChecked = false;
var extractRadioButton = FindName("extract") as RadioButton;
if (extractRadioButton != null)
{
extractRadioButton.IsChecked = true;
}
return;
}
}
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 如果已存在实例,重用;否则创建新实例 // 2. 实例化已有视图(或获取已存在的视图实例)
if (_videoWindow == null) var existingView = new VideoWindow(); // 这里是已有视图的实例
{
_videoWindow = new VideoWindow();
}
// 将视图添加到Grid中 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _videoWindow); AddViewToColumn(mainGrid, existingView);
} }
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -20,32 +20,11 @@
height: 100vh; height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-B-ziti-G.js"></script> <script type="module" crossorigin src="./assets/index-IwiMqFON.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-dkqEFoMI.css"> <link rel="stylesheet" crossorigin href="./assets/index-BaD48VVT.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script>
// 添加调试信息,检查页面是否加载
console.log('HTML 页面已加载');
console.log('等待 JavaScript 模块加载...');
// 检查资源是否加载
window.addEventListener('error', function(e) {
console.error('资源加载错误:', e.filename, e.message);
}, true);
// 延迟检查 Vue 应用是否挂载
setTimeout(function() {
const app = document.getElementById('app');
if (app && app.innerHTML.trim() === '') {
console.error('Vue 应用未正确挂载到 #app 元素');
} else {
console.log('Vue 应用可能已挂载');
}
}, 2000);
</script>
</body> </body>
</html> </html>

View File

@ -30,7 +30,7 @@ info:
# Dev mode configuration # Dev mode configuration
dev_mode: dev_mode:
root_path: . root_path: .
log_level: info # 使用 info 级别,减少非致命错误信息的显示(如 DPI 感知警告) log_level: warn
debounce: 1000 debounce: 1000
ignore: ignore:
dir: dir:

View File

@ -23,26 +23,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script>
// 添加调试信息,检查页面是否加载
console.log('HTML 页面已加载');
console.log('等待 JavaScript 模块加载...');
// 检查资源是否加载
window.addEventListener('error', function(e) {
console.error('资源加载错误:', e.filename, e.message);
}, true);
// 延迟检查 Vue 应用是否挂载
setTimeout(function() {
const app = document.getElementById('app');
if (app && app.innerHTML.trim() === '') {
console.error('Vue 应用未正确挂载到 #app 元素');
} else {
console.log('Vue 应用可能已挂载');
}
}, 2000);
</script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@ -160,13 +160,15 @@ const startModify = async () => {
helpInfo.value = '开始修改元数据...' helpInfo.value = '开始修改元数据...'
try { try {
const total = videos.value.length if (extractService && extractService.ModifyVideosMetadata) {
helpInfo.value = `处理中... (0/${total})` const total = videos.value.length
helpInfo.value = `处理中... (0/${total})`
const modifyResults = await ExtractService.ModifyVideosMetadata(folderPath.value)
if (modifyResults) { const modifyResults = await extractService.ModifyVideosMetadata(folderPath.value)
results.value = modifyResults if (modifyResults) {
helpInfo.value = `全部完成! 共处理 ${total} 个任务,成功 ${successCount.value} 个,失败 ${failCount.value}` results.value = modifyResults
helpInfo.value = `全部完成! 共处理 ${total} 个任务,成功 ${successCount.value} 个,失败 ${failCount.value}`
}
} }
} catch (error) { } catch (error) {
alert('修改失败: ' + error.message) alert('修改失败: ' + error.message)

View File

@ -14,6 +14,6 @@ export default defineConfig({
port: 9245, port: 9245,
strictPort: true strictPort: true
}, },
base: '/' // 使用绝对路径,确保资源能正确加载 base: './'
}) })

View File

@ -2,7 +2,6 @@ package main
import ( import (
"embed" "embed"
"io/fs"
"os" "os"
"videoconcat/services" "videoconcat/services"
@ -13,18 +12,6 @@ import (
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
// 在开发模式下列出嵌入的文件,用于调试
func init() {
if os.Getenv("DEV") == "true" {
fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error {
if err == nil {
services.LogDebugf("嵌入资源: %s (目录: %v)", path, d.IsDir())
}
return nil
})
}
}
// getEmbeddedFFmpeg 在平台特定文件中实现ffmpeg_darwin.go, ffmpeg_windows.go, ffmpeg_linux.go, ffmpeg_default.go // getEmbeddedFFmpeg 在平台特定文件中实现ffmpeg_darwin.go, ffmpeg_windows.go, ffmpeg_linux.go, ffmpeg_default.go
// 根据编译时的 GOOS 自动选择对应的实现 // 根据编译时的 GOOS 自动选择对应的实现
// 注意:此函数没有在此文件中声明,而是在平台特定文件中声明和实现 // 注意:此函数没有在此文件中声明,而是在平台特定文件中声明和实现
@ -37,10 +24,6 @@ func main() {
services.LogDebug("详细日志已启用") services.LogDebug("详细日志已启用")
} }
// 注意:如果看到 "SetProcessDpiAwarenessContext failed" 错误,这是非致命的
// Wails 运行时尝试设置 DPI 感知,但清单文件已经声明了 DPI 感知
// Windows 可能不允许运行时再次设置,这不会影响应用功能
// 初始化 FFmpeg 助手(传递嵌入的文件系统,根据编译平台自动选择) // 初始化 FFmpeg 助手(传递嵌入的文件系统,根据编译平台自动选择)
services.InitFFmpegHelper(getEmbeddedFFmpeg()) services.InitFFmpegHelper(getEmbeddedFFmpeg())
@ -53,24 +36,10 @@ func main() {
services.LogDebug("所有服务创建完成") services.LogDebug("所有服务创建完成")
services.LogDebug("创建 Wails 应用...") services.LogDebug("创建 Wails 应用...")
// 尝试不同的路径映射方式
// 方式1使用子文件系统推荐去除 "assets" 前缀
// 这样 /index.html 会映射到 assets/index.html
assetsFS, err := fs.Sub(assets, "assets")
var useSubFS bool
if err != nil {
services.LogErrorf("创建子文件系统失败: %v使用原始文件系统", err)
assetsFS = assets
useSubFS = false
} else {
services.LogDebug("成功创建子文件系统")
useSubFS = true
}
app := application.New(application.Options{ app := application.New(application.Options{
Name: "VideoConcat", Name: "VideoConcat",
Description: "视频拼接工具", Description: "视频拼接工具",
Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assetsFS)}, Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},
Services: []application.Service{ Services: []application.Service{
application.NewService(videoService), application.NewService(videoService),
application.NewService(extractService), application.NewService(extractService),
@ -86,60 +55,15 @@ func main() {
// 创建窗口 // 创建窗口
services.LogDebug("创建应用窗口...") services.LogDebug("创建应用窗口...")
window := app.Window.NewWithOptions(application.WebviewWindowOptions{ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "视频拼接工具", Title: "视频拼接工具",
Width: 1100, Width: 1100,
Height: 800, Height: 800,
MinWidth: 800, MinWidth: 800,
MinHeight: 600, MinHeight: 600,
DevToolsEnabled: true, // 暂时启用开发者工具以便调试
}) })
// 加载主页面 window.SetURL("index.html")
// 使用子文件系统时,路径映射: services.LogInfo("应用窗口已创建URL: index.html")
// URL /index.html -> 文件系统 index.html (对应 assets/index.html)
// URL /assets/xxx.js -> 文件系统 assets/xxx.js (对应 assets/assets/xxx.js)
urlPath := "/index.html"
if !useSubFS {
// 如果不使用子文件系统,需要调整路径
urlPath = "/assets/index.html"
services.LogWarn("未使用子文件系统URL 路径: " + urlPath)
}
window.SetURL(urlPath)
services.LogInfo("应用窗口已创建URL: " + urlPath)
// 注意:如果页面空白,可能是:
// 1. JavaScript 文件加载失败(检查网络请求)
// 2. Vue 应用未正确挂载
// 3. 资源路径不正确(虽然验证通过,但实际加载时可能有问题)
// 验证文件是否存在并列出所有可用文件(用于调试)
// 使用 LogInfo 确保在所有模式下都能看到
services.LogInfo("=== 开始验证嵌入的资源文件 ===")
// 列出所有文件
fs.WalkDir(assetsFS, ".", func(path string, d fs.DirEntry, err error) error {
if err == nil {
if d.IsDir() {
services.LogInfof("目录: %s", path)
} else {
services.LogInfof("文件: %s", path)
}
}
return nil
})
// 验证关键文件
testFiles := []string{"index.html", "assets/index-B-ziti-G.js", "assets/index-dkqEFoMI.css"}
for _, file := range testFiles {
if data, err := assetsFS.Open(file); err == nil {
data.Close()
services.LogInfof("✓ 验证成功: %s 存在", file)
} else {
services.LogErrorf("✗ 验证失败: %s 不存在 - %v", file, err)
}
}
services.LogInfo("=== 验证完成 ===")
services.LogInfo("=== 应用启动完成,开始运行 ===") services.LogInfo("=== 应用启动完成,开始运行 ===")
if err := app.Run(); err != nil { if err := app.Run(); err != nil {

View File

@ -37,82 +37,30 @@ type ExtractFrameResult struct {
// ListVideos 列出文件夹中的视频文件 // ListVideos 列出文件夹中的视频文件
func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) { func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) {
// 只查找当前目录下的视频文件,排除子目录(特别是输出目录)
pattern := filepath.Join(folderPath, "*.mp4") pattern := filepath.Join(folderPath, "*.mp4")
matches, err := filepath.Glob(pattern) matches, err := filepath.Glob(pattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("查找视频文件失败: %v", err) return nil, fmt.Errorf("查找视频文件失败: %v", err)
} }
return matches, nil
// 过滤掉输出目录中的文件
var filteredMatches []string
outputDir := filepath.Join(folderPath, "out")
outputDirAlt := filepath.Join(folderPath, "output") // 兼容 output 目录
for _, match := range matches {
// 检查文件是否在输出目录中
absMatch, err := filepath.Abs(match)
if err != nil {
continue
}
absOutputDir, _ := filepath.Abs(outputDir)
absOutputDirAlt, _ := filepath.Abs(outputDirAlt)
// 如果文件不在输出目录中,则添加
if !strings.HasPrefix(absMatch, absOutputDir+string(filepath.Separator)) &&
!strings.HasPrefix(absMatch, absOutputDirAlt+string(filepath.Separator)) {
filteredMatches = append(filteredMatches, match)
}
}
LogDebugf("找到 %d 个视频文件(已排除输出目录中的 %d 个文件)", len(filteredMatches), len(matches)-len(filteredMatches))
return filteredMatches, nil
} }
// RemoveFrameRandom 随机删除视频中的一帧 // RemoveFrameRandom 随机删除视频中的一帧
func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error { func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error {
LogDebugf("开始抽帧处理: %s -> %s", inputPath, outputPath)
// 检查输入文件是否存在
if _, err := os.Stat(inputPath); err != nil {
return fmt.Errorf("输入文件不存在: %v", err)
}
// 创建临时目录 // 创建临时目录
tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano())) tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano()))
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
if err := os.MkdirAll(tempDir, 0755); err != nil { os.MkdirAll(tempDir, 0755)
return fmt.Errorf("创建临时目录失败: %v", err)
}
LogDebugf("临时目录已创建: %s", tempDir)
// 获取视频信息 // 获取视频信息
helper := GetFFmpegHelper() helper := GetFFmpegHelper()
if !helper.IsProbeAvailable() { if !helper.IsProbeAvailable() {
return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg") return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
} }
cmd := helper.ProbeCommand("-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
// 使用更详细的 ffprobe 命令获取视频信息
cmd := helper.ProbeCommand("-v", "error", "-show_entries",
"format=duration", "-show_entries", "stream=codec_name,r_frame_rate",
"-of", "default=noprint_wrappers=1:nokey=1", inputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffprobe 命令")
}
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
// 如果上面的命令失败,尝试更简单的方式 return fmt.Errorf("获取视频信息失败: %v", err)
LogWarnf("获取视频信息失败,尝试备用方法: %v, 输出: %s", err, string(output))
cmd = helper.ProbeCommand("-v", "error", "-show_format", "-show_streams", "-of", "json", inputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffprobe 命令")
}
output2, err2 := cmd.Output()
if err2 != nil {
return fmt.Errorf("获取视频信息失败: %v, 输出: %s", err2, string(output2))
}
// 如果备用方法也失败,返回错误
return fmt.Errorf("无法解析视频信息,可能文件已损坏或格式不支持。输出: %s", string(output2))
} }
lines := strings.Split(strings.TrimSpace(string(output)), "\n") lines := strings.Split(strings.TrimSpace(string(output)), "\n")
@ -120,54 +68,33 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
var codecName string var codecName string
var frameRate float64 = 30.0 // 默认帧率 var frameRate float64 = 30.0 // 默认帧率
// 解析 ffprobe 输出 for i, line := range lines {
for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" { if i == 0 && line != "" {
continue fmt.Sscanf(line, "%f", &duration)
} }
// 尝试解析时长(可能是第一个数值行)
if duration == 0 {
var d float64
if n, _ := fmt.Sscanf(line, "%f", &d); n == 1 && d > 0 {
duration = d
LogDebugf("解析到视频时长: %.2f 秒", duration)
}
}
// 查找编解码器
if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") { if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") {
codecName = line codecName = line
LogDebugf("检测到编解码器: %s", codecName)
} }
// 解析帧率
if strings.Contains(line, "/") { if strings.Contains(line, "/") {
parts := strings.Split(line, "/") parts := strings.Split(line, "/")
if len(parts) == 2 { if len(parts) == 2 {
var num, den float64 var num, den float64
if n, _ := fmt.Sscanf(parts[0], "%f", &num); n == 1 { fmt.Sscanf(parts[0], "%f", &num)
if n, _ := fmt.Sscanf(parts[1], "%f", &den); n == 1 && den > 0 { fmt.Sscanf(parts[1], "%f", &den)
frameRate = num / den if den > 0 {
LogDebugf("解析到帧率: %.2f fps", frameRate) frameRate = num / den
} _ = frameRate // 避免未使用变量警告
} }
} }
} }
} }
// 检查时长 // 检查时长
if duration <= 0 {
LogErrorf("无法获取视频时长ffprobe 输出: %s", string(output))
return fmt.Errorf("无法获取视频时长(%.2f秒),文件可能已损坏或格式不支持", duration)
}
if duration < 20 { if duration < 20 {
return fmt.Errorf("视频时长太短(%.2f秒无法抽帧需要至少20秒", duration) return fmt.Errorf("视频时长太短(%.2f秒无法抽帧需要至少20秒", duration)
} }
LogDebugf("视频信息: 时长=%.2f秒, 编解码器=%s, 帧率=%.2f", duration, codecName, frameRate)
// 如果是 HEVC先转换为 H.264 // 如果是 HEVC先转换为 H.264
if codecName == "hevc" { if codecName == "hevc" {
if !helper.IsAvailable() { if !helper.IsAvailable() {
@ -175,103 +102,57 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
} }
videoConvert := filepath.Join(tempDir, "convert.mp4") videoConvert := filepath.Join(tempDir, "convert.mp4")
cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert) cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
if cmd == nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("无法创建 ffmpeg 命令") return fmt.Errorf("转换HEVC失败: %v", err)
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("转换HEVC失败: %v, 输出: %s", err, string(output))
} }
inputPath = videoConvert inputPath = videoConvert
} }
// 获取绝对路径Windows 兼容) // 随机选择要删除的帧时间点(避开开头和结尾)
absInputPath, err := filepath.Abs(inputPath) rand.Seed(time.Now().UnixNano())
minFrameTime := 20.0
maxFrameTime := duration - 5.0
if maxFrameTime <= minFrameTime {
maxFrameTime = minFrameTime + 1.0
}
randomFrame := minFrameTime + rand.Float64()*(maxFrameTime-minFrameTime)
// 分割视频
videoPart1 := filepath.Join(tempDir, "part1.mp4")
videoPart2 := filepath.Join(tempDir, "part2.mp4")
// 第一部分0 到 randomFrame - 0.016
cmd = helper.Command("-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第一部分失败: %v", err)
}
// 第二部分randomFrame 到结束
cmd = helper.Command("-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第二部分失败: %v", err)
}
// 合并视频
concatFile := filepath.Join(tempDir, "concat.txt")
file, err := os.Create(concatFile)
if err != nil { if err != nil {
absInputPath = inputPath return fmt.Errorf("创建concat文件失败: %v", err)
} }
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart1, "\\", "/")))
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/")))
file.Close()
// 计算要删除的帧编号和时间范围 cmd = helper.Command("-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
// 计算总帧数 if err := cmd.Run(); err != nil {
totalFrames := int(duration * frameRate) return fmt.Errorf("合并视频失败: %v", err)
if totalFrames <= 0 {
totalFrames = int(duration * 30) // 默认30fps
}
// 随机选择要删除的帧(避开开头和结尾)
source := rand.NewSource(time.Now().UnixNano())
r := rand.New(source)
minFrame := int(frameRate * 20) // 至少20秒后的帧
maxFrame := totalFrames - int(frameRate*5) - 1 // 至少5秒前的帧
if maxFrame <= minFrame {
maxFrame = minFrame + 100 // 确保有足够的帧可选
}
randomFrameNum := minFrame + r.Intn(maxFrame-minFrame+1)
// 计算删除的时间范围删除一帧约0.016秒)
frameDuration := 1.0 / frameRate
deleteStartTime := float64(randomFrameNum) * frameDuration
deleteEndTime := deleteStartTime + frameDuration
LogDebugf("删除帧: 帧编号=%d, 时间范围=%.6f~%.6f秒, 总时长=%.2f秒",
randomFrameNum, deleteStartTime, deleteEndTime, duration)
// 使用 filter_complex 精确删除指定时间段
// 方法:使用 trim 和 concat filter 来删除指定时间段
// 视频部分:第一部分(0到deleteStartTime) + 第二部分(deleteEndTime到结束)
// 音频部分:第一部分(0到deleteStartTime) + 第二部分(deleteEndTime到结束)
filterComplex := fmt.Sprintf(
"[0:v]trim=start=0:end=%.6f,setpts=PTS-STARTPTS[v1];[0:v]trim=start=%.6f:end=%.6f,setpts=PTS-STARTPTS[v2];[v1][v2]concat=n=2:v=1:a=0[outv];[0:a]atrim=start=0:end=%.6f,asetpts=PTS-STARTPTS[a1];[0:a]atrim=start=%.6f:end=%.6f,asetpts=PTS-STARTPTS[a2];[a1][a2]concat=n=2:v=0:a=1[outa]",
deleteStartTime, deleteEndTime, duration, deleteStartTime, deleteEndTime, duration)
// 使用 filter_complex 一次性处理,确保时长精确
cmd = helper.Command("-i", absInputPath,
"-filter_complex", filterComplex,
"-map", "[outv]", "-map", "[outa]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-g", "30", "-keyint_min", "30", // 确保关键帧间隔,避免卡顿
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", "-y", outputPath)
if cmd == nil {
return fmt.Errorf("无法创建 ffmpeg 命令")
}
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("删除帧失败: %v, 输出: %s", err, string(output))
}
// 验证输出视频时长
if helper.IsProbeAvailable() {
cmd = helper.ProbeCommand("-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", outputPath)
if cmd != nil {
if probeOutput, err := cmd.Output(); err == nil {
var finalDuration float64
if n, _ := fmt.Sscanf(strings.TrimSpace(string(probeOutput)), "%f", &finalDuration); n == 1 {
expectedDuration := duration - frameDuration // 减去删除的一帧时间
diff := finalDuration - expectedDuration
if diff < -0.1 || diff > 0.1 { // 允许0.1秒的误差
LogWarnf("视频时长偏差较大: 期望约%.2f秒, 实际%.2f秒, 偏差%.2f秒",
expectedDuration, finalDuration, diff)
} else {
LogDebugf("视频时长验证: 实际%.2f秒 (期望约%.2f秒, 偏差%.3f秒)",
finalDuration, expectedDuration, diff)
}
}
}
}
} }
// 验证输出文件 // 验证输出文件
fileInfo, err := os.Stat(outputPath) if _, err := os.Stat(outputPath); err != nil {
if err != nil {
return fmt.Errorf("输出文件不存在: %v", err) return fmt.Errorf("输出文件不存在: %v", err)
} }
if fileInfo.Size() == 0 {
return fmt.Errorf("输出文件大小为0可能写入失败")
}
LogInfof("抽帧完成: %s (大小: %d 字节)", outputPath, fileInfo.Size())
return nil return nil
} }
@ -293,7 +174,6 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("创建输出目录失败: %v", err) return nil, fmt.Errorf("创建输出目录失败: %v", err)
} }
LogDebugf("输出目录已创建: %s", outputDir)
// 生成任务列表 // 生成任务列表
type Task struct { type Task struct {
@ -350,14 +230,12 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
current++ current++
var result ExtractFrameResult var result ExtractFrameResult
if err != nil { if err != nil {
LogErrorf("抽帧失败 [%d/%d]: %s, 错误: %v", current, len(tasks), t.VideoPath, err)
result = ExtractFrameResult{ result = ExtractFrameResult{
VideoPath: t.VideoPath, VideoPath: t.VideoPath,
Success: false, Success: false,
Error: err.Error(), Error: err.Error(),
} }
} else { } else {
LogDebugf("抽帧成功 [%d/%d]: %s -> %s", current, len(tasks), t.VideoPath, t.OutputPath)
result = ExtractFrameResult{ result = ExtractFrameResult{
VideoPath: t.VideoPath, VideoPath: t.VideoPath,
OutputPath: t.OutputPath, OutputPath: t.OutputPath,
@ -379,53 +257,14 @@ func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string,
if !helper.IsAvailable() { if !helper.IsAvailable() {
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg") return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
} }
// 获取绝对路径
absInputPath, err := filepath.Abs(inputPath)
if err != nil {
absInputPath = inputPath
}
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405")) comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
cmd := helper.Command("-i", inputPath,
// 首先尝试使用 -c copy流复制这样可以快速处理且不重新编码
cmd := helper.Command("-i", absInputPath,
"-c", "copy", "-c", "copy",
"-metadata", fmt.Sprintf("comment=%s", comment), "-metadata", fmt.Sprintf("comment=%s", comment),
"-y", outputPath) "-y", outputPath)
output, err := cmd.CombinedOutput() if err := cmd.Run(); err != nil {
if err != nil { return fmt.Errorf("修改元数据失败: %v", err)
LogWarnf("使用流复制修改元数据失败,尝试重新编码: %v, 输出: %s", err, string(output))
// 如果流复制失败,尝试重新编码(使用高质量编码)
cmd = helper.Command("-i", absInputPath,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "18", // 高质量编码
"-c:a", "copy", // 音频流复制
"-metadata", fmt.Sprintf("comment=%s", comment),
"-movflags", "+faststart",
"-y", outputPath)
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("修改元数据失败: %v, 输出: %s", err, string(output))
}
}
// 验证输出文件是否存在
if _, err := os.Stat(outputPath); err != nil {
return fmt.Errorf("输出文件不存在: %v", err)
}
// 验证文件大小(确保文件已正确写入)
fileInfo, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("无法验证输出文件: %v", err)
}
if fileInfo.Size() == 0 {
return fmt.Errorf("输出文件大小为0可能写入失败")
} }
return nil return nil
@ -443,7 +282,6 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("创建输出目录失败: %v", err) return nil, fmt.Errorf("创建输出目录失败: %v", err)
} }
LogDebugf("输出目录已创建: %s", outputDir)
semaphore := make(chan struct{}, 10) semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup var wg sync.WaitGroup
@ -458,10 +296,8 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
semaphore <- struct{}{} semaphore <- struct{}{}
defer func() { <-semaphore }() defer func() { <-semaphore }()
// 使用纳秒时间戳作为随机种子,确保每个 goroutine 都有不同的随机数 rand.Seed(time.Now().UnixNano())
source := rand.NewSource(time.Now().UnixNano()) randomNum := rand.Intn(90000) + 10000
r := rand.New(source)
randomNum := r.Intn(90000) + 10000
outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath)) outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath))
outputPath := filepath.Join(outputDir, outputFileName) outputPath := filepath.Join(outputDir, outputFileName)
@ -470,14 +306,12 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
current++ current++
var result ExtractFrameResult var result ExtractFrameResult
if err != nil { if err != nil {
LogErrorf("修改元数据失败 [%d/%d]: %s, 错误: %v", current, len(videos), videoPath, err)
result = ExtractFrameResult{ result = ExtractFrameResult{
VideoPath: videoPath, VideoPath: videoPath,
Success: false, Success: false,
Error: err.Error(), Error: err.Error(),
} }
} else { } else {
LogDebugf("修改元数据成功 [%d/%d]: %s -> %s", current, len(videos), videoPath, outputPath)
result = ExtractFrameResult{ result = ExtractFrameResult{
VideoPath: videoPath, VideoPath: videoPath,
OutputPath: outputPath, OutputPath: outputPath,

View File

@ -169,13 +169,14 @@ func LogWarnf(format string, args ...interface{}) {
func logMessage(level, message string) { func logMessage(level, message string) {
timestamp := time.Now().Format("2006-01-02 15:04:05.000") timestamp := time.Now().Format("2006-01-02 15:04:05.000")
// 控制台输出(仅在开发模式下显示,避免发布版本弹窗 // 控制台输出(开发模式下更详细
if isDevMode { if isDevMode {
log.Printf("[%s] %s %s", timestamp, level, message) log.Printf("[%s] %s %s", timestamp, level, message)
} else {
log.Printf("[%s] %s", level, message)
} }
// 发布模式下不输出到控制台,只写入文件
// 文件输出(开发模式和发布模式都写入) // 文件输出
logFile := filepath.Join(logDir, fmt.Sprintf("log%s.log", time.Now().Format("20060102"))) logFile := filepath.Join(logDir, fmt.Sprintf("log%s.log", time.Now().Format("20060102")))
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil { if err != nil {