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
|
||||
{
|
||||
/// <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
|
||||
{
|
||||
@ -120,8 +33,6 @@ namespace VideoConcat.Models
|
||||
private string[] _videos = [];
|
||||
private int _extractCount = 1;
|
||||
private Dispatcher _dispatcher;
|
||||
private ExtractFrameMode _extractFrameMode = ExtractFrameMode.DeleteFrame;
|
||||
private ObservableCollection<ExtractTaskItem> _taskItems = new();
|
||||
|
||||
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()
|
||||
{
|
||||
// 有视频文件且生成个数大于0时,可以抽帧
|
||||
|
||||
@ -289,15 +289,13 @@ namespace VideoConcat.Services.Video
|
||||
|
||||
LogUtils.Info($"SubVideo:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒");
|
||||
|
||||
// 使用输入时的 -ss 参数(在输入参数中),这样可以更精确地定位,减少重新编码
|
||||
// 使用 -c copy 确保不重新编码
|
||||
// 使用 -ss 和 -t 参数进行裁剪
|
||||
bool result = FFMpegArguments
|
||||
.FromFileInput(inputPath, true, options => options
|
||||
.WithCustomArgument($"-ss {startSec:F3}")) // 在输入时指定起始时间,更精确
|
||||
.Seek(TimeSpan.FromSeconds(startSec))
|
||||
.WithCustomArgument($"-t {duration}")) // 指定时长而不是结束时间
|
||||
.OutputToFile(outputPath, true, options => options
|
||||
.WithCustomArgument($"-t {duration:F3}") // 指定时长
|
||||
.WithVideoCodec("copy") // 明确指定复制视频流
|
||||
.WithAudioCodec("copy")) // 明确指定复制音频流
|
||||
.CopyChannel()) // 复制流,不重新编码
|
||||
.ProcessSynchronously();
|
||||
|
||||
// 验证输出文件是否存在
|
||||
@ -464,522 +462,5 @@ namespace VideoConcat.Services.Video
|
||||
)
|
||||
.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 Microsoft.Expression.Drawing.Core;
|
||||
using System.IO;
|
||||
@ -73,14 +73,9 @@ namespace VideoConcat.ViewModels
|
||||
return;
|
||||
}
|
||||
|
||||
string modeText = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
|
||||
DateTime startTime = DateTime.Now;
|
||||
|
||||
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||
{
|
||||
ExtractWindowModel.HelpInfo = $"处理方案:{modeText} | 视频数量:{ExtractWindowModel.videos.Length} 个 | 每个生成:{extractCount} 个 | 总任务数:{ExtractWindowModel.videos.Length * extractCount} 个 | 开始时间:{startTime:yyyy-MM-dd HH:mm:ss}";
|
||||
// 清空任务列表,以便重新开始
|
||||
ExtractWindowModel.TaskItems.Clear();
|
||||
ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频...";
|
||||
ExtractWindowModel.IsStart = true;
|
||||
ExtractWindowModel.IsCanOperate = false;
|
||||
});
|
||||
@ -91,11 +86,6 @@ namespace VideoConcat.ViewModels
|
||||
int totalTasks = ExtractWindowModel.videos.Length * extractCount;
|
||||
int completedTasks = 0;
|
||||
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)
|
||||
@ -125,10 +115,8 @@ namespace VideoConcat.ViewModels
|
||||
string originalFileName = Path.GetFileNameWithoutExtension(currentVideo);
|
||||
string extension = Path.GetExtension(currentVideo);
|
||||
|
||||
// 生成唯一的文件名:原始名称+方案号+生成日期+序号
|
||||
string modeSuffix = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
|
||||
string dateStr = DateTime.Now.ToString("yyyyMMdd");
|
||||
string _tmpFileName = $"{originalFileName}_{modeSuffix}_{dateStr}_{currentIndex:D4}{extension}";
|
||||
// 生成唯一的文件名:原文件名_序号.扩展名
|
||||
string _tmpFileName = $"{originalFileName}_{currentIndex:D4}{extension}";
|
||||
|
||||
string outPath = Path.Combine(_tmpPath, "out");
|
||||
LogUtils.Info($"准备创建输出目录:{outPath}");
|
||||
@ -155,83 +143,35 @@ namespace VideoConcat.ViewModels
|
||||
string outputPath = Path.Combine(outPath, _tmpFileName);
|
||||
LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}");
|
||||
|
||||
// 定义显示用的变量(提前定义,避免作用域问题)
|
||||
// 显示完整的文件名,让DataGrid的列宽和ToolTip来处理显示
|
||||
string displayFileName = originalFileName;
|
||||
int currentCompleted;
|
||||
double progressPercent;
|
||||
|
||||
// 获取原始文件大小
|
||||
long originalSize = 0;
|
||||
if (File.Exists(currentVideo))
|
||||
{
|
||||
FileInfo originalFileInfo = new FileInfo(currentVideo);
|
||||
originalSize = originalFileInfo.Length;
|
||||
Interlocked.Add(ref totalOriginalSize, originalSize);
|
||||
}
|
||||
|
||||
// 如果文件已存在,跳过(不覆盖)
|
||||
// 如果文件已存在,跳过
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
LogUtils.Info($"文件已存在,跳过:{outputPath}");
|
||||
Interlocked.Increment(ref skipCount);
|
||||
Interlocked.Increment(ref completedTasks);
|
||||
|
||||
currentCompleted = completedTasks;
|
||||
progressPercent = currentCompleted * 100.0 / totalTasks;
|
||||
|
||||
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var taskItem = new Models.ExtractTaskItem
|
||||
{
|
||||
Index = currentIndex.ToString(),
|
||||
FileName = displayFileName,
|
||||
FullFileName = _tmpFileName,
|
||||
Status = "跳过",
|
||||
OriginalSize = "--",
|
||||
OutputSize = "--",
|
||||
SizeChange = "--",
|
||||
Duration = "--",
|
||||
Progress = $"{progressPercent:F1}%"
|
||||
};
|
||||
ExtractWindowModel.TaskItems.Add(taskItem);
|
||||
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 先检查视频时长和获取视频信息
|
||||
IMediaAnalysis? mediaInfo = null;
|
||||
double totalDuration = 0;
|
||||
// 先检查视频时长
|
||||
try
|
||||
{
|
||||
mediaInfo = await FFProbe.AnalyseAsync(currentVideo);
|
||||
totalDuration = mediaInfo.Duration.TotalSeconds;
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(currentVideo);
|
||||
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);
|
||||
errorMessages.Add(errorMsg);
|
||||
|
||||
Interlocked.Increment(ref completedTasks);
|
||||
currentCompleted = completedTasks;
|
||||
progressPercent = currentCompleted * 100.0 / totalTasks;
|
||||
|
||||
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);
|
||||
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -239,156 +179,46 @@ namespace VideoConcat.ViewModels
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogUtils.Error($"检查视频时长失败:{currentVideo}", ex);
|
||||
// 继续处理,让后续方法来处理错误
|
||||
// 继续处理,让 RemoveFrameRandomeAsync 来处理错误
|
||||
}
|
||||
|
||||
// 记录开始时间
|
||||
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;
|
||||
bool success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath);
|
||||
|
||||
// 再次检查文件是否存在
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
FileInfo outputFileInfo = new FileInfo(outputPath);
|
||||
long outputSize = outputFileInfo.Length;
|
||||
Interlocked.Add(ref totalOutputSize, outputSize);
|
||||
Interlocked.Increment(ref successCount);
|
||||
|
||||
double originalMB = originalSize / 1024.0 / 1024.0;
|
||||
double outputMB = outputSize / 1024.0 / 1024.0;
|
||||
double sizeDiff = outputSize - originalSize;
|
||||
double sizeDiffMB = Math.Abs(sizeDiff) / 1024.0 / 1024.0;
|
||||
string sizeChange = sizeDiff > 0 ? $"+{sizeDiffMB:F2}" : sizeDiff < 0 ? $"-{sizeDiffMB:F2}" : "0";
|
||||
string sizeChangeMB = sizeChange + "MB";
|
||||
|
||||
string originalSizeStr = $"{originalMB:F2}MB";
|
||||
string outputSizeStr = $"{outputMB:F2}MB";
|
||||
string durationStr = $"{taskDuration.TotalSeconds:F1}s";
|
||||
|
||||
successMessages.Add($"{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);
|
||||
});
|
||||
FileInfo fileInfo = new FileInfo(outputPath);
|
||||
LogUtils.Info($"抽帧成功:{currentVideo} -> {outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB");
|
||||
}
|
||||
else if (success)
|
||||
{
|
||||
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);
|
||||
});
|
||||
LogUtils.Warn($"抽帧返回成功但文件不存在:{outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
string errorMsg = $"{currentVideo} (第{currentIndex}个) - 处理失败";
|
||||
string videoName = Path.GetFileName(currentVideo);
|
||||
string errorMsg = $"抽帧失败:{videoName}";
|
||||
LogUtils.Error($"{errorMsg} -> {outputPath}");
|
||||
errorMessages.Add(errorMsg);
|
||||
}
|
||||
|
||||
// 更新完成计数
|
||||
Interlocked.Increment(ref completedTasks);
|
||||
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);
|
||||
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 在异常处理中重新获取文件名等信息
|
||||
string originalFileNameForError = Path.GetFileNameWithoutExtension(currentVideo);
|
||||
string errorMsg = $"抽帧异常:{currentVideo} (第{currentIndex}个) - {ex.Message}";
|
||||
string videoName = Path.GetFileName(currentVideo);
|
||||
string errorMsg = $"抽帧异常:{videoName} (第{currentIndex}个) - {ex.Message}";
|
||||
LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex);
|
||||
errorMessages.Add(errorMsg);
|
||||
|
||||
Interlocked.Increment(ref completedTasks);
|
||||
int currentCompletedForError = completedTasks;
|
||||
double progressPercentForError = currentCompletedForError * 100.0 / totalTasks;
|
||||
string displayFileNameForError = originalFileNameForError.Length > 15 ? originalFileNameForError.Substring(0, 12) + "..." : originalFileNameForError.PadRight(15);
|
||||
|
||||
string modeSuffixForError2 = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
|
||||
string dateStrForError2 = DateTime.Now.ToString("yyyyMMdd");
|
||||
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var taskItem = new Models.ExtractTaskItem
|
||||
{
|
||||
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);
|
||||
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||
});
|
||||
}
|
||||
finally
|
||||
@ -402,9 +232,6 @@ namespace VideoConcat.ViewModels
|
||||
|
||||
await Task.WhenAll(_tasks);
|
||||
|
||||
// 计算总耗时
|
||||
TimeSpan totalDuration = DateTime.Now - startTime;
|
||||
|
||||
// 统计实际生成的文件数量
|
||||
int actualFileCount = 0;
|
||||
string outputDir = "";
|
||||
@ -421,40 +248,25 @@ namespace VideoConcat.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
// 构建最终详细信息
|
||||
double totalOriginalMB = totalOriginalSize / 1024.0 / 1024.0;
|
||||
double totalOutputMB = totalOutputSize / 1024.0 / 1024.0;
|
||||
double totalSizeDiff = totalOutputSize - totalOriginalSize;
|
||||
double totalSizeDiffMB = Math.Abs(totalSizeDiff) / 1024.0 / 1024.0;
|
||||
string totalSizeChange = totalSizeDiff > 0 ? $"+{totalSizeDiffMB:F2}MB" : totalSizeDiff < 0 ? $"-{totalSizeDiffMB:F2}MB" : "0MB";
|
||||
|
||||
string summaryInfo = $"\n处理完成!\n" +
|
||||
$"处理方案:{modeText} | 总任务数:{totalTasks} 个\n" +
|
||||
$"成功数量:{successCount} 个 | 失败数量:{errorMessages.Count} 个 | 跳过数量:{skipCount} 个\n" +
|
||||
$"实际生成:{actualFileCount} 个文件\n" +
|
||||
$"总原始大小:{totalOriginalMB:F2}MB | 总输出大小:{totalOutputMB:F2}MB | 大小变化:{totalSizeChange}\n" +
|
||||
$"总耗时:{totalDuration.TotalSeconds:F1}秒 ({totalDuration.TotalMinutes:F1}分钟) | 平均速度:{(successCount > 0 ? totalDuration.TotalSeconds / successCount : 0):F2}秒/个\n" +
|
||||
$"输出目录:{outputDir}\n" +
|
||||
$"完成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}";
|
||||
// 构建最终信息
|
||||
string summaryInfo = $"全部完成! 共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件\n输出目录:{outputDir}";
|
||||
|
||||
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||
{
|
||||
ExtractWindowModel.HelpInfo += $"\n{summaryInfo}";
|
||||
|
||||
// 如果有错误信息,显示汇总
|
||||
if (errorMessages.Count > 0)
|
||||
{
|
||||
ExtractWindowModel.HelpInfo += $"\n错误信息(共{errorMessages.Count}个):";
|
||||
foreach (var error in errorMessages)
|
||||
string errorSummary = string.Join("\n", errorMessages);
|
||||
ExtractWindowModel.HelpInfo = $"{summaryInfo}\n\n错误信息(共{errorMessages.Count}个):\n{errorSummary}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtractWindowModel.HelpInfo += $"\n • {error}";
|
||||
ExtractWindowModel.HelpInfo = summaryInfo;
|
||||
}
|
||||
}
|
||||
|
||||
ExtractWindowModel.IsStart = false;
|
||||
ExtractWindowModel.IsCanOperate = true;
|
||||
});
|
||||
LogUtils.Info($"处理完成,成功={successCount}, 失败={errorMessages.Count}, 跳过={skipCount}, 总耗时={totalDuration.TotalSeconds:F1}秒");
|
||||
LogUtils.Info($"抽帧处理完成,共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件,输出目录:{outputDir}");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -23,65 +23,23 @@
|
||||
<Grid>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"></RowDefinition>
|
||||
<RowDefinition Height="40"></RowDefinition>
|
||||
<RowDefinition Height="*"></RowDefinition>
|
||||
</Grid.RowDefinitions>
|
||||
<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>
|
||||
<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}"/>
|
||||
<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}"/>
|
||||
<Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">处理方式:</Label>
|
||||
<RadioButton Content="方案1" IsChecked="{Binding ExtractWindowModel.IsDeleteFrameMode,Mode=TwoWay}" Margin="5,2" FontSize="14" VerticalAlignment="Center" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}" GroupName="ExtractMode" ToolTip="随机删除一个非关键帧"/>
|
||||
<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}"/>
|
||||
<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}"/>
|
||||
<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}"/>
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1">
|
||||
<Grid>
|
||||
<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>
|
||||
<TextBlock Text="{Binding ExtractWindowModel.HelpInfo,Mode=TwoWay}"></TextBlock>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@ -13,8 +13,7 @@ namespace VideoConcat.Views
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private VideoWindow? _videoWindow;
|
||||
private ExtractWindow? _extractWindow;
|
||||
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@ -30,10 +29,10 @@ namespace VideoConcat.Views
|
||||
SetupGridColumns(mainGrid);
|
||||
|
||||
// 2. 实例化已有视图(或获取已存在的视图实例)
|
||||
_videoWindow = new VideoWindow(); // 这里是已有视图的实例
|
||||
var existingView = new VideoWindow(); // 这里是已有视图的实例
|
||||
|
||||
// 3. 将视图添加到指定列中(例如第1列,索引为1)
|
||||
AddViewToColumn(mainGrid, _videoWindow);
|
||||
AddViewToColumn(mainGrid, existingView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -75,60 +74,24 @@ namespace VideoConcat.Views
|
||||
{
|
||||
if (radioButton.Name == "extract")
|
||||
{
|
||||
// 切换到extract tab,检查是否有正在进行的处理
|
||||
if (_extractWindow != null)
|
||||
{
|
||||
var viewModel = _extractWindow.DataContext as ViewModels.ExtractWindowViewModel;
|
||||
if (viewModel != null && viewModel.ExtractWindowModel.IsStart)
|
||||
{
|
||||
// 有正在进行的处理,允许切换,因为用户可能想查看进度
|
||||
// 不做任何阻止操作
|
||||
}
|
||||
}
|
||||
|
||||
SetupGridColumns(mainGrid);
|
||||
// 2. 实例化已有视图(或获取已存在的视图实例)
|
||||
var existingView = new ExtractWindow(); // 这里是已有视图的实例
|
||||
|
||||
// 如果已存在实例,重用;否则创建新实例
|
||||
if (_extractWindow == null)
|
||||
{
|
||||
_extractWindow = new ExtractWindow();
|
||||
}
|
||||
|
||||
// 将视图添加到Grid中
|
||||
AddViewToColumn(mainGrid, _extractWindow);
|
||||
// 3. 将视图添加到指定列中(例如第1列,索引为1)
|
||||
AddViewToColumn(mainGrid, existingView);
|
||||
}
|
||||
|
||||
if (radioButton.Name == "video")
|
||||
{
|
||||
// 从extract切换到video时,检查是否有正在进行的处理
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 为目标Grid创建列定义
|
||||
SetupGridColumns(mainGrid);
|
||||
|
||||
// 如果已存在实例,重用;否则创建新实例
|
||||
if (_videoWindow == null)
|
||||
{
|
||||
_videoWindow = new VideoWindow();
|
||||
}
|
||||
// 2. 实例化已有视图(或获取已存在的视图实例)
|
||||
var existingView = new VideoWindow(); // 这里是已有视图的实例
|
||||
|
||||
// 将视图添加到Grid中
|
||||
AddViewToColumn(mainGrid, _videoWindow);
|
||||
// 3. 将视图添加到指定列中(例如第1列,索引为1)
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-B-ziti-G.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-dkqEFoMI.css">
|
||||
<script type="module" crossorigin src="./assets/index-IwiMqFON.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BaD48VVT.css">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ info:
|
||||
# Dev mode configuration
|
||||
dev_mode:
|
||||
root_path: .
|
||||
log_level: info # 使用 info 级别,减少非致命错误信息的显示(如 DPI 感知警告)
|
||||
log_level: warn
|
||||
debounce: 1000
|
||||
ignore:
|
||||
dir:
|
||||
|
||||
@ -23,26 +23,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -160,14 +160,16 @@ const startModify = async () => {
|
||||
helpInfo.value = '开始修改元数据...'
|
||||
|
||||
try {
|
||||
if (extractService && extractService.ModifyVideosMetadata) {
|
||||
const total = videos.value.length
|
||||
helpInfo.value = `处理中... (0/${total})`
|
||||
|
||||
const modifyResults = await ExtractService.ModifyVideosMetadata(folderPath.value)
|
||||
const modifyResults = await extractService.ModifyVideosMetadata(folderPath.value)
|
||||
if (modifyResults) {
|
||||
results.value = modifyResults
|
||||
helpInfo.value = `全部完成! 共处理 ${total} 个任务,成功 ${successCount.value} 个,失败 ${failCount.value} 个`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('修改失败: ' + error.message)
|
||||
helpInfo.value = '处理失败: ' + error.message
|
||||
|
||||
@ -14,6 +14,6 @@ export default defineConfig({
|
||||
port: 9245,
|
||||
strictPort: true
|
||||
},
|
||||
base: '/' // 使用绝对路径,确保资源能正确加载
|
||||
base: './'
|
||||
})
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"videoconcat/services"
|
||||
@ -13,18 +12,6 @@ import (
|
||||
//go:embed assets
|
||||
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)
|
||||
// 根据编译时的 GOOS 自动选择对应的实现
|
||||
// 注意:此函数没有在此文件中声明,而是在平台特定文件中声明和实现
|
||||
@ -37,10 +24,6 @@ func main() {
|
||||
services.LogDebug("详细日志已启用")
|
||||
}
|
||||
|
||||
// 注意:如果看到 "SetProcessDpiAwarenessContext failed" 错误,这是非致命的
|
||||
// Wails 运行时尝试设置 DPI 感知,但清单文件已经声明了 DPI 感知
|
||||
// Windows 可能不允许运行时再次设置,这不会影响应用功能
|
||||
|
||||
// 初始化 FFmpeg 助手(传递嵌入的文件系统,根据编译平台自动选择)
|
||||
services.InitFFmpegHelper(getEmbeddedFFmpeg())
|
||||
|
||||
@ -53,24 +36,10 @@ func main() {
|
||||
services.LogDebug("所有服务创建完成")
|
||||
|
||||
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{
|
||||
Name: "VideoConcat",
|
||||
Description: "视频拼接工具",
|
||||
Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assetsFS)},
|
||||
Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},
|
||||
Services: []application.Service{
|
||||
application.NewService(videoService),
|
||||
application.NewService(extractService),
|
||||
@ -91,55 +60,10 @@ func main() {
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
DevToolsEnabled: true, // 暂时启用开发者工具以便调试
|
||||
})
|
||||
|
||||
// 加载主页面
|
||||
// 使用子文件系统时,路径映射:
|
||||
// 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("=== 验证完成 ===")
|
||||
window.SetURL("index.html")
|
||||
services.LogInfo("应用窗口已创建,URL: index.html")
|
||||
|
||||
services.LogInfo("=== 应用启动完成,开始运行 ===")
|
||||
if err := app.Run(); err != nil {
|
||||
|
||||
@ -37,82 +37,30 @@ type ExtractFrameResult struct {
|
||||
|
||||
// ListVideos 列出文件夹中的视频文件
|
||||
func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) {
|
||||
// 只查找当前目录下的视频文件,排除子目录(特别是输出目录)
|
||||
pattern := filepath.Join(folderPath, "*.mp4")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找视频文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 过滤掉输出目录中的文件
|
||||
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
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// RemoveFrameRandom 随机删除视频中的一帧
|
||||
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()))
|
||||
defer os.RemoveAll(tempDir)
|
||||
if err := os.MkdirAll(tempDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建临时目录失败: %v", err)
|
||||
}
|
||||
LogDebugf("临时目录已创建: %s", tempDir)
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
// 获取视频信息
|
||||
helper := GetFFmpegHelper()
|
||||
if !helper.IsProbeAvailable() {
|
||||
return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
|
||||
}
|
||||
|
||||
// 使用更详细的 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 命令")
|
||||
}
|
||||
cmd := helper.ProbeCommand("-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// 如果上面的命令失败,尝试更简单的方式
|
||||
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))
|
||||
return fmt.Errorf("获取视频信息失败: %v", err)
|
||||
}
|
||||
|
||||
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 frameRate float64 = 30.0 // 默认帧率
|
||||
|
||||
// 解析 ffprobe 输出
|
||||
for _, line := range lines {
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
if i == 0 && line != "" {
|
||||
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") {
|
||||
codecName = line
|
||||
LogDebugf("检测到编解码器: %s", codecName)
|
||||
}
|
||||
|
||||
// 解析帧率
|
||||
if strings.Contains(line, "/") {
|
||||
parts := strings.Split(line, "/")
|
||||
if len(parts) == 2 {
|
||||
var num, den float64
|
||||
if n, _ := fmt.Sscanf(parts[0], "%f", &num); n == 1 {
|
||||
if n, _ := fmt.Sscanf(parts[1], "%f", &den); n == 1 && den > 0 {
|
||||
fmt.Sscanf(parts[0], "%f", &num)
|
||||
fmt.Sscanf(parts[1], "%f", &den)
|
||||
if den > 0 {
|
||||
frameRate = num / den
|
||||
LogDebugf("解析到帧率: %.2f fps", frameRate)
|
||||
}
|
||||
_ = frameRate // 避免未使用变量警告
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查时长
|
||||
if duration <= 0 {
|
||||
LogErrorf("无法获取视频时长,ffprobe 输出: %s", string(output))
|
||||
return fmt.Errorf("无法获取视频时长(%.2f秒),文件可能已损坏或格式不支持", duration)
|
||||
}
|
||||
if duration < 20 {
|
||||
return fmt.Errorf("视频时长太短(%.2f秒),无法抽帧(需要至少20秒)", duration)
|
||||
}
|
||||
|
||||
LogDebugf("视频信息: 时长=%.2f秒, 编解码器=%s, 帧率=%.2f", duration, codecName, frameRate)
|
||||
|
||||
// 如果是 HEVC,先转换为 H.264
|
||||
if codecName == "hevc" {
|
||||
if !helper.IsAvailable() {
|
||||
@ -175,103 +102,57 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
|
||||
}
|
||||
videoConvert := filepath.Join(tempDir, "convert.mp4")
|
||||
cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
|
||||
if cmd == nil {
|
||||
return fmt.Errorf("无法创建 ffmpeg 命令")
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("转换HEVC失败: %v, 输出: %s", err, string(output))
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("转换HEVC失败: %v", err)
|
||||
}
|
||||
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 {
|
||||
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()
|
||||
|
||||
// 计算要删除的帧编号和时间范围
|
||||
// 计算总帧数
|
||||
totalFrames := int(duration * frameRate)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd = helper.Command("-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("合并视频失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证输出文件
|
||||
fileInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
if _, err := os.Stat(outputPath); err != nil {
|
||||
return fmt.Errorf("输出文件不存在: %v", err)
|
||||
}
|
||||
if fileInfo.Size() == 0 {
|
||||
return fmt.Errorf("输出文件大小为0,可能写入失败")
|
||||
}
|
||||
|
||||
LogInfof("抽帧完成: %s (大小: %d 字节)", outputPath, fileInfo.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -293,7 +174,6 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建输出目录失败: %v", err)
|
||||
}
|
||||
LogDebugf("输出目录已创建: %s", outputDir)
|
||||
|
||||
// 生成任务列表
|
||||
type Task struct {
|
||||
@ -350,14 +230,12 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
|
||||
current++
|
||||
var result ExtractFrameResult
|
||||
if err != nil {
|
||||
LogErrorf("抽帧失败 [%d/%d]: %s, 错误: %v", current, len(tasks), t.VideoPath, err)
|
||||
result = ExtractFrameResult{
|
||||
VideoPath: t.VideoPath,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
LogDebugf("抽帧成功 [%d/%d]: %s -> %s", current, len(tasks), t.VideoPath, t.OutputPath)
|
||||
result = ExtractFrameResult{
|
||||
VideoPath: t.VideoPath,
|
||||
OutputPath: t.OutputPath,
|
||||
@ -379,53 +257,14 @@ func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string,
|
||||
if !helper.IsAvailable() {
|
||||
return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
|
||||
}
|
||||
|
||||
// 获取绝对路径
|
||||
absInputPath, err := filepath.Abs(inputPath)
|
||||
if err != nil {
|
||||
absInputPath = inputPath
|
||||
}
|
||||
|
||||
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
|
||||
|
||||
// 首先尝试使用 -c copy(流复制),这样可以快速处理且不重新编码
|
||||
cmd := helper.Command("-i", absInputPath,
|
||||
cmd := helper.Command("-i", inputPath,
|
||||
"-c", "copy",
|
||||
"-metadata", fmt.Sprintf("comment=%s", comment),
|
||||
"-y", outputPath)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
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,可能写入失败")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("修改元数据失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -443,7 +282,6 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建输出目录失败: %v", err)
|
||||
}
|
||||
LogDebugf("输出目录已创建: %s", outputDir)
|
||||
|
||||
semaphore := make(chan struct{}, 10)
|
||||
var wg sync.WaitGroup
|
||||
@ -458,10 +296,8 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// 使用纳秒时间戳作为随机种子,确保每个 goroutine 都有不同的随机数
|
||||
source := rand.NewSource(time.Now().UnixNano())
|
||||
r := rand.New(source)
|
||||
randomNum := r.Intn(90000) + 10000
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
randomNum := rand.Intn(90000) + 10000
|
||||
outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath))
|
||||
outputPath := filepath.Join(outputDir, outputFileName)
|
||||
|
||||
@ -470,14 +306,12 @@ func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath st
|
||||
current++
|
||||
var result ExtractFrameResult
|
||||
if err != nil {
|
||||
LogErrorf("修改元数据失败 [%d/%d]: %s, 错误: %v", current, len(videos), videoPath, err)
|
||||
result = ExtractFrameResult{
|
||||
VideoPath: videoPath,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
LogDebugf("修改元数据成功 [%d/%d]: %s -> %s", current, len(videos), videoPath, outputPath)
|
||||
result = ExtractFrameResult{
|
||||
VideoPath: videoPath,
|
||||
OutputPath: outputPath,
|
||||
|
||||
@ -169,13 +169,14 @@ func LogWarnf(format string, args ...interface{}) {
|
||||
func logMessage(level, message string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||||
|
||||
// 控制台输出(仅在开发模式下显示,避免发布版本弹窗)
|
||||
// 控制台输出(开发模式下更详细)
|
||||
if isDevMode {
|
||||
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")))
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user