Compare commits
No commits in common. "21796d79b698a23ca1fc0e1787d2879e7764790f" and "7717bf13a17ae357950ec80b7ba81764e9673ffe" have entirely different histories.
21796d79b6
...
7717bf13a1
@ -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时,可以抽帧
|
||||||
|
|||||||
@ -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到宽度-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 { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
1
wails/assets/assets/index-BaD48VVT.css
Normal file
1
wails/assets/assets/index-BaD48VVT.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
17
wails/assets/assets/index-IwiMqFON.js
Normal file
17
wails/assets/assets/index-IwiMqFON.js
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -14,6 +14,6 @@ export default defineConfig({
|
|||||||
port: 9245,
|
port: 9245,
|
||||||
strictPort: true
|
strictPort: true
|
||||||
},
|
},
|
||||||
base: '/' // 使用绝对路径,确保资源能正确加载
|
base: './'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user