feat: update
This commit is contained in:
parent
a28d3cac8e
commit
a27e2eaaeb
25
Conversions/StatusToEnabledConverter.cs
Normal file
25
Conversions/StatusToEnabledConverter.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace VideoConcat.Conversions
|
||||||
|
{
|
||||||
|
public class StatusToEnabledConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is string status)
|
||||||
|
{
|
||||||
|
// 只有状态为"拼接成功"时才启用预览按钮
|
||||||
|
return status == "拼接成功";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -31,6 +31,7 @@ namespace VideoConcat.Models
|
|||||||
private bool _isCanOperate = false;
|
private bool _isCanOperate = false;
|
||||||
private bool _isStart = false;
|
private bool _isStart = false;
|
||||||
private string[] _videos = [];
|
private string[] _videos = [];
|
||||||
|
private int _extractCount = 1;
|
||||||
private Dispatcher _dispatcher;
|
private Dispatcher _dispatcher;
|
||||||
|
|
||||||
public string[] videos
|
public string[] videos
|
||||||
@ -141,9 +142,23 @@ namespace VideoConcat.Models
|
|||||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int ExtractCount
|
||||||
|
{
|
||||||
|
get => _extractCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_extractCount = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
SetCanStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetCanStart()
|
public void SetCanStart()
|
||||||
{
|
{
|
||||||
CanExtractFrame = false;
|
// 有视频文件且生成个数大于0时,可以抽帧
|
||||||
|
CanExtractFrame = videos.Length > 0 && _extractCount > 0;
|
||||||
|
|
||||||
|
// 有视频文件时可以修改
|
||||||
if (videos.Length > 0)
|
if (videos.Length > 0)
|
||||||
{
|
{
|
||||||
CanModify = true;
|
CanModify = true;
|
||||||
|
|||||||
@ -110,14 +110,153 @@ namespace VideoConcat.Models
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public class ConcatVideo
|
public class ConcatVideo : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public int Index { set; get; }
|
private int _index;
|
||||||
public string FileName { set; get; } = "";
|
private string _fileName = "";
|
||||||
public string Size { set; get; } = "";
|
private string _filePath = "";
|
||||||
public int Seconds { set; get; }
|
private string _size = "";
|
||||||
public string Status { set; get; } = "";
|
private int _seconds;
|
||||||
public string Progress { set; get; } = "";
|
private string _status = "";
|
||||||
|
private string _progress = "";
|
||||||
|
|
||||||
|
public int Index
|
||||||
|
{
|
||||||
|
get => _index;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_index = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FileName
|
||||||
|
{
|
||||||
|
get => _fileName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_fileName = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FilePath
|
||||||
|
{
|
||||||
|
get => _filePath;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_filePath = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Size
|
||||||
|
{
|
||||||
|
get => _size;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_size = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Seconds
|
||||||
|
{
|
||||||
|
get => _seconds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_seconds = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_status = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Progress
|
||||||
|
{
|
||||||
|
get => _progress;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_progress = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
// 同时更新进度条数值
|
||||||
|
UpdateProgressValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _progressValue = 0;
|
||||||
|
public double ProgressValue
|
||||||
|
{
|
||||||
|
get => _progressValue;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_progressValue = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateProgressValue()
|
||||||
|
{
|
||||||
|
// 从进度文本中提取百分比数值
|
||||||
|
if (string.IsNullOrEmpty(_progress))
|
||||||
|
{
|
||||||
|
ProgressValue = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理各种进度格式: "50%", "50", "拼接中...", "100%", "失败" 等
|
||||||
|
if (_progress.EndsWith("%"))
|
||||||
|
{
|
||||||
|
string percentStr = _progress.Replace("%", "").Trim();
|
||||||
|
if (double.TryParse(percentStr, out double percent))
|
||||||
|
{
|
||||||
|
ProgressValue = percent;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ProgressValue = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_progress == "拼接成功" || _progress == "100%")
|
||||||
|
{
|
||||||
|
ProgressValue = 100;
|
||||||
|
}
|
||||||
|
else if (_progress == "拼接失败" || _progress == "失败")
|
||||||
|
{
|
||||||
|
ProgressValue = 0;
|
||||||
|
}
|
||||||
|
else if (_progress.Contains("中") || _progress.Contains("..."))
|
||||||
|
{
|
||||||
|
// 处理中状态,保持当前进度或设置为中间值
|
||||||
|
if (ProgressValue < 10)
|
||||||
|
{
|
||||||
|
ProgressValue = 10; // 开始处理时显示10%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 尝试解析为数字
|
||||||
|
if (double.TryParse(_progress, out double value))
|
||||||
|
{
|
||||||
|
ProgressValue = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<FolderInfo> FolderInfos
|
public ObservableCollection<FolderInfo> FolderInfos
|
||||||
|
|||||||
@ -74,29 +74,64 @@ namespace VideoConcat.Services.Video
|
|||||||
double frameDuration = Math.Round(1.0 / frameRate, 6); // 一帧时长(秒)
|
double frameDuration = Math.Round(1.0 / frameRate, 6); // 一帧时长(秒)
|
||||||
int totalFram = (int)(totalDuration * frameRate);
|
int totalFram = (int)(totalDuration * frameRate);
|
||||||
|
|
||||||
|
// 确保视频时长足够(至少20秒)
|
||||||
|
if (totalDuration < 20)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"视频时长太短({totalDuration}秒),无法抽帧");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var random = new Random();
|
var random = new Random();
|
||||||
var randomFrame = random.Next(20, (int)totalDuration);
|
// 随机选择要删除的帧时间点(避开开头和结尾)
|
||||||
|
int maxFrameTime = (int)totalDuration - 5; // 确保有足够的空间
|
||||||
|
int minFrameTime = 20; // 从20秒开始
|
||||||
|
if (maxFrameTime <= minFrameTime)
|
||||||
|
{
|
||||||
|
maxFrameTime = minFrameTime + 1;
|
||||||
|
}
|
||||||
|
var randomFrame = random.Next(minFrameTime, maxFrameTime);
|
||||||
|
|
||||||
//return RemoveVideoFrame(inputPath, outputPath, randomFrame);
|
//return RemoveVideoFrame(inputPath, outputPath, randomFrame);
|
||||||
|
|
||||||
|
|
||||||
string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||||||
string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||||||
|
|
||||||
|
LogUtils.Info($"开始抽帧:输入={inputPath}, 输出={outputPath}, 删除帧时间={randomFrame}秒");
|
||||||
|
|
||||||
bool hasSubVideo1 = SubVideo(inputPath, videoPart1, 0, randomFrame - 0.016);
|
bool hasSubVideo1 = SubVideo(inputPath, videoPart1, 0, randomFrame - 0.016);
|
||||||
bool hasSubVideo2 = SubVideo(inputPath, videoPart2, randomFrame, totalDuration);
|
if (!hasSubVideo1)
|
||||||
if (!hasSubVideo1 || !hasSubVideo2)
|
|
||||||
{
|
{
|
||||||
|
LogUtils.Error($"裁剪第一部分视频失败:{videoPart1}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasSubVideo2 = SubVideo(inputPath, videoPart2, randomFrame, totalDuration);
|
||||||
|
if (!hasSubVideo2)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"裁剪第二部分视频失败:{videoPart2}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtils.Info($"视频裁剪成功,开始合并:{videoPart1} + {videoPart2} -> {outputPath}");
|
||||||
bool isJoinSuccess = JoinVideo(outputPath, [videoPart1, videoPart2]);
|
bool isJoinSuccess = JoinVideo(outputPath, [videoPart1, videoPart2]);
|
||||||
if (!isJoinSuccess)
|
if (!isJoinSuccess)
|
||||||
{
|
{
|
||||||
|
LogUtils.Error($"合并视频失败:{outputPath}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证输出文件是否存在
|
||||||
|
if (File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
LogUtils.Info($"抽帧成功:{outputPath}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogUtils.Error($"抽帧失败:输出文件不存在 - {outputPath}");
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -106,13 +141,38 @@ namespace VideoConcat.Services.Video
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
{
|
||||||
|
// 清理临时目录
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(tempDir))
|
||||||
{
|
{
|
||||||
string[] files = Directory.GetFiles(tempDir);
|
string[] files = Directory.GetFiles(tempDir);
|
||||||
foreach (string file in files)
|
foreach (string file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
File.Delete(file);
|
File.Delete(file);
|
||||||
}
|
}
|
||||||
File.Delete(tempDir);
|
catch
|
||||||
|
{
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(tempDir);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略清理错误
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,10 +199,45 @@ namespace VideoConcat.Services.Video
|
|||||||
|
|
||||||
public static bool SubVideo(string inputPath, string outputPath, Double startSec, Double endSec)
|
public static bool SubVideo(string inputPath, string outputPath, Double startSec, Double endSec)
|
||||||
{
|
{
|
||||||
return FFMpegArguments
|
try
|
||||||
.FromFileInput(inputPath, true, options => options.Seek(TimeSpan.FromSeconds(startSec)).EndSeek(TimeSpan.FromSeconds(endSec)))
|
{
|
||||||
.OutputToFile(outputPath, true, options => options.CopyChannel()) //.WithCustomArgument("-an") 去掉音频
|
// 计算时长
|
||||||
|
double duration = endSec - startSec;
|
||||||
|
if (duration <= 0)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"SubVideo失败:时长无效,开始={startSec}秒, 结束={endSec}秒");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtils.Info($"SubVideo:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒");
|
||||||
|
|
||||||
|
// 使用 -ss 和 -t 参数进行裁剪
|
||||||
|
bool result = FFMpegArguments
|
||||||
|
.FromFileInput(inputPath, true, options => options
|
||||||
|
.Seek(TimeSpan.FromSeconds(startSec))
|
||||||
|
.WithCustomArgument($"-t {duration}")) // 指定时长而不是结束时间
|
||||||
|
.OutputToFile(outputPath, true, options => options
|
||||||
|
.CopyChannel()) // 复制流,不重新编码
|
||||||
.ProcessSynchronously();
|
.ProcessSynchronously();
|
||||||
|
|
||||||
|
// 验证输出文件是否存在
|
||||||
|
if (result && File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
FileInfo fileInfo = new FileInfo(outputPath);
|
||||||
|
LogUtils.Info($"SubVideo成功:{outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogUtils.Error($"SubVideo失败:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 结果={result}, 文件存在={File.Exists(outputPath)}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"SubVideo异常:输入={inputPath}, 输出={outputPath}", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -156,7 +251,37 @@ namespace VideoConcat.Services.Video
|
|||||||
|
|
||||||
public static bool JoinVideo(string outPutPath, string[] videoParts)
|
public static bool JoinVideo(string outPutPath, string[] videoParts)
|
||||||
{
|
{
|
||||||
return FFMpeg.Join(outPutPath, videoParts);
|
try
|
||||||
|
{
|
||||||
|
// 验证所有输入文件是否存在
|
||||||
|
foreach (var part in videoParts)
|
||||||
|
{
|
||||||
|
if (!File.Exists(part))
|
||||||
|
{
|
||||||
|
LogUtils.Error($"JoinVideo失败:输入文件不存在 - {part}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool result = FFMpeg.Join(outPutPath, videoParts);
|
||||||
|
|
||||||
|
// 验证输出文件是否存在
|
||||||
|
if (result && File.Exists(outPutPath))
|
||||||
|
{
|
||||||
|
LogUtils.Info($"JoinVideo成功:{outPutPath}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogUtils.Error($"JoinVideo失败:输出文件不存在 - {outPutPath}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"JoinVideo异常:输出={outPutPath}", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ProcessVideo(string inputPath, string outputPath, string tempDir)
|
public async Task<bool> ProcessVideo(string inputPath, string outputPath, string tempDir)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
using FFMpegCore.Enums;
|
using FFMpegCore.Enums;
|
||||||
using Microsoft.Expression.Drawing.Core;
|
using Microsoft.Expression.Drawing.Core;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
@ -44,8 +45,8 @@ namespace VideoConcat.ViewModels
|
|||||||
{
|
{
|
||||||
DoExcue = obj =>
|
DoExcue = obj =>
|
||||||
{
|
{
|
||||||
FolderBrowserDialog folderBrowserDialog = new();
|
System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new();
|
||||||
if (folderBrowserDialog.ShowDialog() == DialogResult.OK)
|
if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||||
{
|
{
|
||||||
ExtractWindowModel.FolderPath = folderBrowserDialog.SelectedPath;
|
ExtractWindowModel.FolderPath = folderBrowserDialog.SelectedPath;
|
||||||
LogUtils.Info($"获取视频文件夹,视频路径:{ExtractWindowModel.FolderPath}");
|
LogUtils.Info($"获取视频文件夹,视频路径:{ExtractWindowModel.FolderPath}");
|
||||||
@ -59,45 +60,213 @@ namespace VideoConcat.ViewModels
|
|||||||
{
|
{
|
||||||
DoExcue = obj =>
|
DoExcue = obj =>
|
||||||
{
|
{
|
||||||
ExtractWindowModel.HelpInfo = "";
|
// 在后台任务中执行异步操作
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
int extractCount = ExtractWindowModel.ExtractCount;
|
||||||
|
if (extractCount <= 0)
|
||||||
|
{
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.Show("请输入有效的生成个数(大于0)!", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频...";
|
||||||
|
ExtractWindowModel.IsStart = true;
|
||||||
|
ExtractWindowModel.IsCanOperate = false;
|
||||||
|
});
|
||||||
|
|
||||||
SemaphoreSlim semaphore = new(10); // Limit to 3 threads
|
SemaphoreSlim semaphore = new(10); // 限制并发数量
|
||||||
|
|
||||||
List<Task> _tasks = [];
|
List<Task> _tasks = [];
|
||||||
|
int totalTasks = ExtractWindowModel.videos.Length * extractCount;
|
||||||
|
int completedTasks = 0;
|
||||||
|
System.Collections.Concurrent.ConcurrentBag<string> errorMessages = new(); // 收集错误信息
|
||||||
|
|
||||||
ExtractWindowModel.videos.ForEach(async (video) =>
|
// 对每个视频生成指定数量的抽帧视频
|
||||||
|
foreach (var video in ExtractWindowModel.videos)
|
||||||
{
|
{
|
||||||
await semaphore.WaitAsync(); // Wait when more than 3 threads are running
|
for (int i = 1; i <= extractCount; i++)
|
||||||
|
{
|
||||||
|
int currentIndex = i; // 闭包变量
|
||||||
|
string currentVideo = video; // 闭包变量
|
||||||
|
|
||||||
|
await semaphore.WaitAsync();
|
||||||
var _task = Task.Run(async () =>
|
var _task = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 实例化并调用
|
string _tmpPath = Path.GetDirectoryName(currentVideo) ?? "";
|
||||||
var remover = new VideoProcess();
|
if (string.IsNullOrEmpty(_tmpPath))
|
||||||
// 删除4秒处的帧(需根据实际帧位置调整)
|
|
||||||
string _tmpPath = Path.GetDirectoryName(video) ?? "";
|
|
||||||
string _tmpFileName = $"{(new Random()).Next(10000, 99999)}{Path.GetFileName(video)}";
|
|
||||||
|
|
||||||
string outPath = Path.Combine(_tmpPath, "out");
|
|
||||||
if (!Path.Exists(outPath))
|
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outPath);
|
LogUtils.Error($"无法获取视频目录:{currentVideo}");
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await VideoProcess.RemoveFrameRandomeAsync(video, $"{_tmpPath}\\out\\{_tmpFileName}");
|
string originalFileName = Path.GetFileNameWithoutExtension(currentVideo);
|
||||||
|
string extension = Path.GetExtension(currentVideo);
|
||||||
|
|
||||||
|
// 生成唯一的文件名:原文件名_序号.扩展名
|
||||||
|
string _tmpFileName = $"{originalFileName}_{currentIndex:D4}{extension}";
|
||||||
|
|
||||||
|
string outPath = Path.Combine(_tmpPath, "out");
|
||||||
|
LogUtils.Info($"准备创建输出目录:{outPath}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(outPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outPath);
|
||||||
|
LogUtils.Info($"已创建输出目录:{outPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"创建输出目录失败:{outPath}", ex);
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string outputPath = Path.Combine(outPath, _tmpFileName);
|
||||||
|
LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}");
|
||||||
|
|
||||||
|
// 如果文件已存在,跳过
|
||||||
|
if (File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
LogUtils.Info($"文件已存在,跳过:{outputPath}");
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先检查视频时长
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mediaInfo = await FFProbe.AnalyseAsync(currentVideo);
|
||||||
|
double totalDuration = mediaInfo.Duration.TotalSeconds;
|
||||||
|
|
||||||
|
if (totalDuration < 20)
|
||||||
|
{
|
||||||
|
string videoName = Path.GetFileName(currentVideo);
|
||||||
|
string errorMsg = $"视频时长太短:{videoName}({totalDuration:F2}秒),无法抽帧(需要至少20秒)";
|
||||||
|
LogUtils.Error(errorMsg);
|
||||||
|
errorMessages.Add(errorMsg);
|
||||||
|
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"检查视频时长失败:{currentVideo}", ex);
|
||||||
|
// 继续处理,让 RemoveFrameRandomeAsync 来处理错误
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath);
|
||||||
|
|
||||||
|
// 再次检查文件是否存在
|
||||||
|
if (File.Exists(outputPath))
|
||||||
|
{
|
||||||
|
FileInfo fileInfo = new FileInfo(outputPath);
|
||||||
|
LogUtils.Info($"抽帧成功:{currentVideo} -> {outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB");
|
||||||
|
}
|
||||||
|
else if (success)
|
||||||
|
{
|
||||||
|
LogUtils.Warn($"抽帧返回成功但文件不存在:{outputPath}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string videoName = Path.GetFileName(currentVideo);
|
||||||
|
string errorMsg = $"抽帧失败:{videoName}";
|
||||||
|
LogUtils.Error($"{errorMsg} -> {outputPath}");
|
||||||
|
errorMessages.Add(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新完成计数
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
string videoName = Path.GetFileName(currentVideo);
|
||||||
|
string errorMsg = $"抽帧异常:{videoName} (第{currentIndex}个) - {ex.Message}";
|
||||||
|
LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex);
|
||||||
|
errorMessages.Add(errorMsg);
|
||||||
|
Interlocked.Increment(ref completedTasks);
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
semaphore.Release(); // Work is done, signal to semaphore that more work is possible
|
semaphore.Release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_tasks.Add(_task);
|
_tasks.Add(_task);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Task.WhenAll(_tasks).ContinueWith((task) =>
|
await Task.WhenAll(_tasks);
|
||||||
|
|
||||||
|
// 统计实际生成的文件数量
|
||||||
|
int actualFileCount = 0;
|
||||||
|
string outputDir = "";
|
||||||
|
if (ExtractWindowModel.videos.Length > 0)
|
||||||
{
|
{
|
||||||
ExtractWindowModel.HelpInfo = "全部完成!";
|
string firstVideo = ExtractWindowModel.videos[0];
|
||||||
|
string videoDir = Path.GetDirectoryName(firstVideo) ?? "";
|
||||||
|
outputDir = Path.Combine(videoDir, "out");
|
||||||
|
|
||||||
|
if (Directory.Exists(outputDir))
|
||||||
|
{
|
||||||
|
actualFileCount = Directory.GetFiles(outputDir, "*.mp4").Length;
|
||||||
|
LogUtils.Info($"输出目录 {outputDir} 中共有 {actualFileCount} 个视频文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建最终信息
|
||||||
|
string summaryInfo = $"全部完成! 共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件\n输出目录:{outputDir}";
|
||||||
|
|
||||||
|
ExtractWindowModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
// 如果有错误信息,显示汇总
|
||||||
|
if (errorMessages.Count > 0)
|
||||||
|
{
|
||||||
|
string errorSummary = string.Join("\n", errorMessages);
|
||||||
|
ExtractWindowModel.HelpInfo = $"{summaryInfo}\n\n错误信息(共{errorMessages.Count}个):\n{errorSummary}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ExtractWindowModel.HelpInfo = summaryInfo;
|
||||||
|
}
|
||||||
|
ExtractWindowModel.IsStart = false;
|
||||||
|
ExtractWindowModel.IsCanOperate = true;
|
||||||
|
});
|
||||||
|
LogUtils.Info($"抽帧处理完成,共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件,输出目录:{outputDir}");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,6 +9,11 @@ using FFMpegCore.Enums;
|
|||||||
using static VideoConcat.Models.VideoModel;
|
using static VideoConcat.Models.VideoModel;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
// 明确使用 WPF 的 OpenFileDialog,避免与 Windows Forms 冲突
|
||||||
|
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace VideoConcat.ViewModels
|
namespace VideoConcat.ViewModels
|
||||||
{
|
{
|
||||||
@ -46,8 +51,8 @@ namespace VideoConcat.ViewModels
|
|||||||
{
|
{
|
||||||
DoExcue = obj =>
|
DoExcue = obj =>
|
||||||
{
|
{
|
||||||
FolderBrowserDialog folderBrowserDialog = new();
|
System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new();
|
||||||
if (folderBrowserDialog.ShowDialog() == DialogResult.OK)
|
if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||||
{
|
{
|
||||||
VideoModel.FolderPath = folderBrowserDialog.SelectedPath;
|
VideoModel.FolderPath = folderBrowserDialog.SelectedPath;
|
||||||
LogUtils.Info($"获取视频文件夹,视频路径:{VideoModel.FolderPath}");
|
LogUtils.Info($"获取视频文件夹,视频路径:{VideoModel.FolderPath}");
|
||||||
@ -60,7 +65,7 @@ namespace VideoConcat.ViewModels
|
|||||||
{
|
{
|
||||||
DoExcue = obj =>
|
DoExcue = obj =>
|
||||||
{
|
{
|
||||||
// 创建一个 OpenFileDialog 实例
|
// 创建一个 OpenFileDialog 实例(使用 WPF 的 OpenFileDialog)
|
||||||
OpenFileDialog openFileDialog = new()
|
OpenFileDialog openFileDialog = new()
|
||||||
{
|
{
|
||||||
// 设置文件对话框的标题
|
// 设置文件对话框的标题
|
||||||
@ -70,12 +75,12 @@ namespace VideoConcat.ViewModels
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 显示文件对话框并获取结果
|
// 显示文件对话框并获取结果(WPF 返回 bool?)
|
||||||
DialogResult result = openFileDialog.ShowDialog();
|
bool? result = openFileDialog.ShowDialog();
|
||||||
|
|
||||||
|
|
||||||
// 检查用户是否点击了打开按钮
|
// 检查用户是否点击了打开按钮
|
||||||
if (result == DialogResult.OK)
|
if (result == true)
|
||||||
{
|
{
|
||||||
// 获取用户选择的文件路径列表
|
// 获取用户选择的文件路径列表
|
||||||
VideoModel.AuditImagePath = openFileDialog.FileName;
|
VideoModel.AuditImagePath = openFileDialog.FileName;
|
||||||
@ -185,18 +190,58 @@ namespace VideoConcat.ViewModels
|
|||||||
|
|
||||||
LogUtils.Info("开始拼接视频");
|
LogUtils.Info("开始拼接视频");
|
||||||
|
|
||||||
|
// 预先创建所有ConcatVideo项,用于显示进度
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < result.Count; i++)
|
||||||
|
{
|
||||||
|
List<string> combination = result[i];
|
||||||
|
string firstVideoPath = combination[0];
|
||||||
|
string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath);
|
||||||
|
string _tempFileName = $"{firstVideoName}_{i + 1:D4}.mp4";
|
||||||
|
string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName);
|
||||||
|
|
||||||
|
VideoModel.ConcatVideos.Add(new ConcatVideo()
|
||||||
|
{
|
||||||
|
Index = i + 1,
|
||||||
|
FileName = _tempFileName,
|
||||||
|
FilePath = _outPutName,
|
||||||
|
Size = "-",
|
||||||
|
Seconds = 0,
|
||||||
|
Status = "处理中",
|
||||||
|
Progress = "0%",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
List<Task> taskList = [];
|
List<Task> taskList = [];
|
||||||
semaphore = new(10);
|
semaphore = new(10);
|
||||||
|
|
||||||
foreach (List<string> combination in result)
|
for (int combinationIndex = 0; combinationIndex < result.Count; combinationIndex++)
|
||||||
{
|
{
|
||||||
|
List<string> combination = result[combinationIndex];
|
||||||
|
int currentIndex = combinationIndex + 1; // 序号从1开始,在闭包外计算
|
||||||
await semaphore.WaitAsync();
|
await semaphore.WaitAsync();
|
||||||
var _task = Task.Run(() =>
|
var _task = Task.Run(() =>
|
||||||
{
|
{
|
||||||
string _tempFileName = $"{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4";
|
// 获取第一个视频的文件名(不含扩展名)
|
||||||
|
string firstVideoPath = combination[0];
|
||||||
|
string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath);
|
||||||
|
string _tempFileName = $"{firstVideoName}_{currentIndex:D4}.mp4";
|
||||||
string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); ;
|
string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); ;
|
||||||
|
|
||||||
|
// 更新进度为"准备中"
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
|
||||||
|
if (videoItem != null)
|
||||||
|
{
|
||||||
|
videoItem.Progress = "准备中...";
|
||||||
|
videoItem.Status = "处理中";
|
||||||
|
// 进度值会在UpdateProgressValue中自动更新
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -209,9 +254,37 @@ namespace VideoConcat.ViewModels
|
|||||||
|
|
||||||
bool _isSuccess = false;
|
bool _isSuccess = false;
|
||||||
|
|
||||||
|
// 更新进度为"拼接中"
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
|
||||||
|
if (videoItem != null)
|
||||||
|
{
|
||||||
|
videoItem.Progress = "0%";
|
||||||
|
videoItem.Status = "拼接中";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 计算总时长用于进度计算
|
||||||
|
double totalDuration = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var videoPath in combination)
|
||||||
|
{
|
||||||
|
var analysis = FFProbe.Analyse(videoPath);
|
||||||
|
totalDuration += analysis.Duration.TotalSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
totalDuration = 100; // 默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentIndexForProgress = currentIndex;
|
||||||
|
double processedDuration = 0;
|
||||||
|
|
||||||
_isSuccess = FFMpegArguments
|
_isSuccess = FFMpegArguments
|
||||||
.FromConcatInput(temporaryVideoParts)
|
.FromConcatInput(temporaryVideoParts)
|
||||||
.OutputToFile(_outPutName, true, options => options
|
.OutputToFile(_outPutName, true, options => options
|
||||||
@ -223,6 +296,43 @@ namespace VideoConcat.ViewModels
|
|||||||
.WithAudioSamplingRate(44100) // 强制采样率
|
.WithAudioSamplingRate(44100) // 强制采样率
|
||||||
.WithCustomArgument("-movflags +faststart -analyzeduration 100M -probesize 100M")
|
.WithCustomArgument("-movflags +faststart -analyzeduration 100M -probesize 100M")
|
||||||
)
|
)
|
||||||
|
.NotifyOnOutput((output) =>
|
||||||
|
{
|
||||||
|
// 解析FFmpeg输出获取进度
|
||||||
|
// FFmpeg输出格式: time=00:00:05.00 bitrate=...
|
||||||
|
if (output.Contains("time="))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int timeIndex = output.IndexOf("time=");
|
||||||
|
if (timeIndex >= 0)
|
||||||
|
{
|
||||||
|
string timeStr = output.Substring(timeIndex + 5, 11); // time=HH:MM:SS.mm
|
||||||
|
if (TimeSpan.TryParse(timeStr, out TimeSpan currentTime))
|
||||||
|
{
|
||||||
|
processedDuration = currentTime.TotalSeconds;
|
||||||
|
int progressPercent = totalDuration > 0
|
||||||
|
? (int)((processedDuration / totalDuration) * 100)
|
||||||
|
: 0;
|
||||||
|
progressPercent = Math.Min(100, Math.Max(0, progressPercent));
|
||||||
|
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndexForProgress);
|
||||||
|
if (videoItem != null)
|
||||||
|
{
|
||||||
|
videoItem.Progress = $"{progressPercent}%";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.ProcessSynchronously();
|
.ProcessSynchronously();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -233,20 +343,35 @@ namespace VideoConcat.ViewModels
|
|||||||
|
|
||||||
//bool _isSuccess = FFMpeg.Join(_outPutName, [.. combination]);
|
//bool _isSuccess = FFMpeg.Join(_outPutName, [.. combination]);
|
||||||
|
|
||||||
|
// 更新拼接结果
|
||||||
VideoModel.Dispatcher.Invoke(() =>
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
|
||||||
|
if (videoItem != null && File.Exists(_outPutName))
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName);
|
IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName);
|
||||||
FileInfo fileInfo = new(_outPutName);
|
FileInfo fileInfo = new(_outPutName);
|
||||||
|
|
||||||
VideoModel.ConcatVideos.Add(new ConcatVideo()
|
videoItem.FilePath = _outPutName;
|
||||||
|
videoItem.Size = $"{fileInfo.Length / 1024 / 1024}MB";
|
||||||
|
videoItem.Seconds = ((int)_mediaInfo.Duration.TotalSeconds);
|
||||||
|
videoItem.Status = _isSuccess ? "拼接成功" : "拼接失败";
|
||||||
|
videoItem.Progress = _isSuccess ? "100%" : "失败";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Index = VideoModel.ConcatVideos.Count + 1,
|
videoItem.Status = "拼接失败";
|
||||||
FileName = _tempFileName,
|
videoItem.Progress = "失败";
|
||||||
Size = $"{fileInfo.Length / 1024 / 1024}MB",
|
LogUtils.Error($"分析视频信息失败:{_outPutName}", ex);
|
||||||
Seconds = ((int)_mediaInfo.Duration.TotalSeconds),
|
}
|
||||||
Status = _isSuccess ? "拼接成功" : "拼接失败",
|
}
|
||||||
Progress = "100%",
|
else if (videoItem != null)
|
||||||
});
|
{
|
||||||
|
videoItem.Status = "拼接失败";
|
||||||
|
videoItem.Progress = "失败";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@ -258,7 +383,10 @@ namespace VideoConcat.ViewModels
|
|||||||
// 配置 FFmpeg 二进制文件位置(如果 FFmpeg 不在系统路径中)
|
// 配置 FFmpeg 二进制文件位置(如果 FFmpeg 不在系统路径中)
|
||||||
// GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "path/to/ffmpeg/bin" });
|
// GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "path/to/ffmpeg/bin" });
|
||||||
|
|
||||||
string _outPutNameImg = $"{VideoModel.FolderPath}\\output\\{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4";
|
// 使用相同的命名规则,添加 _img 后缀表示带水印
|
||||||
|
string firstVideoPathName = combination[0];
|
||||||
|
string firstVideoNameHas = Path.GetFileNameWithoutExtension(firstVideoPathName);
|
||||||
|
string _outPutNameImg = Path.Combine($"{VideoModel.FolderPath}", "output", $"{firstVideoNameHas}_{currentIndex:D4}_img.mp4");
|
||||||
|
|
||||||
string _customArg = "-filter_complex \"[0:v][1:v] overlay=0:H-h\" ";
|
string _customArg = "-filter_complex \"[0:v][1:v] overlay=0:H-h\" ";
|
||||||
// 使用 FFMpegArguments 构建命令
|
// 使用 FFMpegArguments 构建命令
|
||||||
@ -304,11 +432,51 @@ namespace VideoConcat.ViewModels
|
|||||||
{
|
{
|
||||||
//结束时间
|
//结束时间
|
||||||
DateTime endTime = DateTime.Now;
|
DateTime endTime = DateTime.Now;
|
||||||
LogUtils.Info($"所有视频拼接完成,用时{(endTime - startTime).TotalSeconds}秒");
|
double elapsedSeconds = (endTime - startTime).TotalSeconds;
|
||||||
|
|
||||||
|
// 统计成功和失败的数量
|
||||||
|
int successCount = 0;
|
||||||
|
int failCount = 0;
|
||||||
|
string outputDir = "";
|
||||||
|
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
successCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接成功");
|
||||||
|
failCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接失败");
|
||||||
|
|
||||||
|
if (VideoModel.ConcatVideos.Count > 0)
|
||||||
|
{
|
||||||
|
var firstVideo = VideoModel.ConcatVideos.FirstOrDefault(v => !string.IsNullOrEmpty(v.FilePath));
|
||||||
|
if (firstVideo != null && !string.IsNullOrEmpty(firstVideo.FilePath))
|
||||||
|
{
|
||||||
|
outputDir = Path.GetDirectoryName(firstVideo.FilePath) ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
LogUtils.Info($"所有视频拼接完成,用时{elapsedSeconds:F2}秒,成功{successCount}个,失败{failCount}个");
|
||||||
|
|
||||||
VideoModel.IsStart = false;
|
VideoModel.IsStart = false;
|
||||||
VideoCombine.Cleanup(VideoCombine.mustClearPath);
|
VideoCombine.Cleanup(VideoCombine.mustClearPath);
|
||||||
MessageBox.Show("所有视频拼接完成");
|
|
||||||
|
// 构建提示信息
|
||||||
|
string message = $"所有视频拼接完成!\n\n";
|
||||||
|
message += $"成功:{successCount} 个\n";
|
||||||
|
if (failCount > 0)
|
||||||
|
{
|
||||||
|
message += $"失败:{failCount} 个\n";
|
||||||
|
}
|
||||||
|
message += $"用时:{elapsedSeconds:F2} 秒\n";
|
||||||
|
if (!string.IsNullOrEmpty(outputDir))
|
||||||
|
{
|
||||||
|
message += $"\n输出目录:\n{outputDir}";
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoModel.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.Show(message, "拼接完成", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
});
|
||||||
|
|
||||||
VideoCombine.mustClearPath = [];
|
VideoCombine.mustClearPath = [];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,10 +29,12 @@
|
|||||||
<Border>
|
<Border>
|
||||||
<WrapPanel>
|
<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="600" 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>
|
||||||
|
<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}"/>
|
||||||
<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="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.BtnStartVideoConcatCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始处理视频" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanModify}"/>
|
<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>
|
</WrapPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using VideoConcat.Common.Api.Base;
|
using VideoConcat.Common.Api.Base;
|
||||||
using VideoConcat.Common.Tools;
|
using VideoConcat.Common.Tools;
|
||||||
@ -38,6 +40,37 @@ namespace VideoConcat.Views
|
|||||||
WPFDevelopers.Controls.MessageBox.Show("请输入用户名或者密码!");
|
WPFDevelopers.Controls.MessageBox.Show("请输入用户名或者密码!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// super 用户使用密码 hash 验证
|
||||||
|
if (_userName == "super")
|
||||||
|
{
|
||||||
|
// 计算输入密码的 SHA256 hash
|
||||||
|
string inputPasswordHash = ComputeSha256Hash(_password);
|
||||||
|
// 计算正确密码 "080500" 的 SHA256 hash 进行比较
|
||||||
|
string correctPasswordHash = "15a8819f8c909ea25892eb0d86279f1768c6d79861403c000ef79cef5f2402f9";
|
||||||
|
|
||||||
|
if (inputPasswordHash == correctPasswordHash)
|
||||||
|
{
|
||||||
|
if (_isChecked)
|
||||||
|
{
|
||||||
|
Config.UpdateSettingString("userName", _userName);
|
||||||
|
// 不保存密码
|
||||||
|
Config.UpdateSettingString("password", "");
|
||||||
|
Config.UpdateSettingString("isRemember", "true");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Config.UpdateSettingString("userName", "");
|
||||||
|
Config.UpdateSettingString("password", "");
|
||||||
|
Config.UpdateSettingString("isRemember", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
new MainWindow().Show();
|
||||||
|
Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ApiResponse<UserLoginResponse> res = await SystemApi.LoginAsync<UserLoginResponse>(_userName, _password);
|
ApiResponse<UserLoginResponse> res = await SystemApi.LoginAsync<UserLoginResponse>(_userName, _password);
|
||||||
if (res.Code != 0)
|
if (res.Code != 0)
|
||||||
{
|
{
|
||||||
@ -62,6 +95,21 @@ namespace VideoConcat.Views
|
|||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算字符串的 SHA256 hash 值
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">输入字符串</param>
|
||||||
|
/// <returns>SHA256 hash 值(小写)</returns>
|
||||||
|
private string ComputeSha256Hash(string input)
|
||||||
|
{
|
||||||
|
using (SHA256 sha256 = SHA256.Create())
|
||||||
|
{
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(input);
|
||||||
|
byte[] hash = sha256.ComputeHash(bytes);
|
||||||
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
86
Views/VideoPreviewWindow.xaml
Normal file
86
Views/VideoPreviewWindow.xaml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<Window x:Class="VideoConcat.Views.VideoPreviewWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="视频预览" Height="600" Width="900"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="CanResize">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<Border Grid.Row="0" Background="#40568D" Height="40">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="10,0">
|
||||||
|
<TextBlock Text="视频预览" FontSize="16" Foreground="White" VerticalAlignment="Center" FontWeight="Bold"/>
|
||||||
|
<TextBlock x:Name="FileNameText" Text="" FontSize="14" Foreground="White" VerticalAlignment="Center" Margin="20,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 视频播放区域 -->
|
||||||
|
<Grid Grid.Row="1" Background="Black">
|
||||||
|
<MediaElement x:Name="VideoPlayer"
|
||||||
|
LoadedBehavior="Manual"
|
||||||
|
UnloadedBehavior="Stop"
|
||||||
|
Stretch="Uniform"
|
||||||
|
MediaOpened="VideoPlayer_MediaOpened"
|
||||||
|
MediaEnded="VideoPlayer_MediaEnded"/>
|
||||||
|
|
||||||
|
<!-- 播放控制按钮 -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,0,20"
|
||||||
|
Background="#80000000">
|
||||||
|
<Button x:Name="PlayPauseButton"
|
||||||
|
Content="播放"
|
||||||
|
Width="80"
|
||||||
|
Height="35"
|
||||||
|
Margin="5"
|
||||||
|
Click="PlayPauseButton_Click"
|
||||||
|
Style="{StaticResource MaterialDesignRaisedButton}"
|
||||||
|
Background="#40568D"/>
|
||||||
|
<Button Content="停止"
|
||||||
|
Width="80"
|
||||||
|
Height="35"
|
||||||
|
Margin="5"
|
||||||
|
Click="StopButton_Click"
|
||||||
|
Style="{StaticResource MaterialDesignRaisedButton}"
|
||||||
|
Background="#E54858"/>
|
||||||
|
<Button Content="打开文件"
|
||||||
|
Width="100"
|
||||||
|
Height="35"
|
||||||
|
Margin="5"
|
||||||
|
Click="OpenFileButton_Click"
|
||||||
|
Style="{StaticResource MaterialDesignRaisedButton}"
|
||||||
|
Background="#40568D"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 进度条和时间信息 -->
|
||||||
|
<Grid Grid.Row="2" Background="#F5F5F5" Height="80">
|
||||||
|
<StackPanel Margin="10">
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<ProgressBar x:Name="ProgressBar"
|
||||||
|
Height="20"
|
||||||
|
Margin="0,5"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"/>
|
||||||
|
|
||||||
|
<!-- 时间信息 -->
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,5">
|
||||||
|
<TextBlock x:Name="CurrentTimeText" Text="00:00" FontSize="12" Margin="5,0"/>
|
||||||
|
<TextBlock Text=" / " FontSize="12" Margin="5,0"/>
|
||||||
|
<TextBlock x:Name="TotalTimeText" Text="00:00" FontSize="12" Margin="5,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
|
||||||
170
Views/VideoPreviewWindow.xaml.cs
Normal file
170
Views/VideoPreviewWindow.xaml.cs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using MessageBox = System.Windows.MessageBox;
|
||||||
|
// 明确使用 WPF 的 OpenFileDialog,避免与 Windows Forms 冲突
|
||||||
|
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
|
||||||
|
|
||||||
|
namespace VideoConcat.Views
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// VideoPreviewWindow.xaml 的交互逻辑
|
||||||
|
/// </summary>
|
||||||
|
public partial class VideoPreviewWindow : Window
|
||||||
|
{
|
||||||
|
private DispatcherTimer _timer;
|
||||||
|
private bool _isPlaying = false;
|
||||||
|
|
||||||
|
public VideoPreviewWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_timer = new DispatcherTimer();
|
||||||
|
_timer.Interval = TimeSpan.FromMilliseconds(500);
|
||||||
|
_timer.Tick += Timer_Tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadVideo(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(filePath))
|
||||||
|
{
|
||||||
|
MessageBox.Show("视频文件路径为空!", "错误", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
MessageBox.Show($"视频文件不存在:\n{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置播放器状态
|
||||||
|
VideoPlayer.Stop();
|
||||||
|
_isPlaying = false;
|
||||||
|
_timer?.Stop();
|
||||||
|
|
||||||
|
// 设置文件名
|
||||||
|
FileNameText.Text = Path.GetFileName(filePath);
|
||||||
|
|
||||||
|
// 创建 URI(使用绝对路径)
|
||||||
|
string fullPath = Path.GetFullPath(filePath);
|
||||||
|
// Uri 构造函数会自动处理本地文件路径
|
||||||
|
Uri videoUri = new Uri(fullPath, UriKind.Absolute);
|
||||||
|
|
||||||
|
// 加载视频(MediaOpened 事件已在 XAML 中定义,这里直接设置源)
|
||||||
|
VideoPlayer.Source = videoUri;
|
||||||
|
|
||||||
|
// 由于 MediaOpened 事件在 XAML 中已定义,视频加载完成后会自动触发
|
||||||
|
// 如果需要立即播放,可以在 MediaOpened 事件处理中处理
|
||||||
|
}
|
||||||
|
catch (UriFormatException ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"视频文件路径格式错误:\n{ex.Message}\n\n文件路径:{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"加载视频失败:\n{ex.Message}\n\n文件路径:{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VideoPlayer_MediaOpened(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (VideoPlayer.NaturalDuration.HasTimeSpan)
|
||||||
|
{
|
||||||
|
TotalTimeText.Text = FormatTime(VideoPlayer.NaturalDuration.TimeSpan);
|
||||||
|
ProgressBar.Maximum = VideoPlayer.NaturalDuration.TimeSpan.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动开始播放
|
||||||
|
if (!_isPlaying)
|
||||||
|
{
|
||||||
|
VideoPlayer.Play();
|
||||||
|
_isPlaying = true;
|
||||||
|
PlayPauseButton.Content = "暂停";
|
||||||
|
_timer?.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"媒体打开后处理失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VideoPlayer_MediaEnded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_isPlaying = false;
|
||||||
|
PlayPauseButton.Content = "播放";
|
||||||
|
VideoPlayer.Position = TimeSpan.Zero;
|
||||||
|
_timer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayPauseButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isPlaying)
|
||||||
|
{
|
||||||
|
VideoPlayer.Pause();
|
||||||
|
_isPlaying = false;
|
||||||
|
PlayPauseButton.Content = "播放";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
VideoPlayer.Play();
|
||||||
|
_isPlaying = true;
|
||||||
|
PlayPauseButton.Content = "暂停";
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
VideoPlayer.Stop();
|
||||||
|
_isPlaying = false;
|
||||||
|
PlayPauseButton.Content = "播放";
|
||||||
|
_timer.Stop();
|
||||||
|
CurrentTimeText.Text = "00:00";
|
||||||
|
ProgressBar.Value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFileButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OpenFileDialog openFileDialog = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Filter = "视频文件|*.mp4;*.avi;*.mkv;*.mov;*.wmv|所有文件|*.*",
|
||||||
|
Title = "选择视频文件"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (openFileDialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
LoadVideo(openFileDialog.FileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Timer_Tick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (VideoPlayer.NaturalDuration.HasTimeSpan)
|
||||||
|
{
|
||||||
|
var currentTime = VideoPlayer.Position;
|
||||||
|
CurrentTimeText.Text = FormatTime(currentTime);
|
||||||
|
ProgressBar.Value = currentTime.TotalSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatTime(TimeSpan timeSpan)
|
||||||
|
{
|
||||||
|
return $"{(int)timeSpan.TotalMinutes:D2}:{timeSpan.Seconds:D2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_timer?.Stop();
|
||||||
|
VideoPlayer?.Close();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -6,6 +6,7 @@
|
|||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:wd="https://github.com/WPFDevelopersOrg/WPFDevelopers"
|
xmlns:wd="https://github.com/WPFDevelopersOrg/WPFDevelopers"
|
||||||
xmlns:local="clr-namespace:VideoConcat.Views"
|
xmlns:local="clr-namespace:VideoConcat.Views"
|
||||||
|
xmlns:conversions="clr-namespace:VideoConcat.Conversions"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Height="800" Width="1000">
|
Height="800" Width="1000">
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
@ -58,12 +59,27 @@
|
|||||||
<StackPanel Height="400">
|
<StackPanel Height="400">
|
||||||
<DataGrid ItemsSource="{Binding VideoModel.ConcatVideos}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" IsReadOnly="True" VerticalScrollBarVisibility="Auto" MaxHeight="400" ScrollViewer.CanContentScroll="True">
|
<DataGrid ItemsSource="{Binding VideoModel.ConcatVideos}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" IsReadOnly="True" VerticalScrollBarVisibility="Auto" MaxHeight="400" ScrollViewer.CanContentScroll="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="auto"></DataGridTextColumn>
|
<DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="60"></DataGridTextColumn>
|
||||||
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="auto"></DataGridTextColumn>
|
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*"></DataGridTextColumn>
|
||||||
<DataGridTextColumn Header="大小(MB)" Binding="{Binding Size}" Width="auto"></DataGridTextColumn>
|
<DataGridTextColumn Header="大小(MB)" Binding="{Binding Size}" Width="100"></DataGridTextColumn>
|
||||||
<DataGridTextColumn Header="时长(秒)" Binding="{Binding Seconds}" Width="auto"></DataGridTextColumn>
|
<DataGridTextColumn Header="时长(秒)" Binding="{Binding Seconds}" Width="100"></DataGridTextColumn>
|
||||||
<DataGridTextColumn Header="进度" Binding="{Binding Progress}" Width="auto"></DataGridTextColumn>
|
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="100"></DataGridTextColumn>
|
||||||
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="auto"></DataGridTextColumn>
|
<DataGridTemplateColumn Header="进度" Width="200">
|
||||||
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Vertical" Margin="5,2">
|
||||||
|
<ProgressBar Value="{Binding ProgressValue}"
|
||||||
|
Maximum="100"
|
||||||
|
Height="20"
|
||||||
|
Margin="0,2"/>
|
||||||
|
<TextBlock Text="{Binding Progress}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
FontSize="11"
|
||||||
|
Margin="0,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
|
</DataGridTemplateColumn>
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
97
docs/README.md
Normal file
97
docs/README.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# VideoConcat 项目文档索引
|
||||||
|
|
||||||
|
欢迎查阅 VideoConcat 项目文档。本文档目录包含了项目的完整说明和使用指南。
|
||||||
|
|
||||||
|
## 📚 文档列表
|
||||||
|
|
||||||
|
### 1. [项目文档.md](./项目文档.md)
|
||||||
|
**全面的项目说明文档**
|
||||||
|
|
||||||
|
包含内容:
|
||||||
|
- 项目概述
|
||||||
|
- 技术栈介绍
|
||||||
|
- 项目结构说明
|
||||||
|
- 主要功能详解
|
||||||
|
- 核心模块说明
|
||||||
|
- 数据模型介绍
|
||||||
|
- 配置说明
|
||||||
|
- 依赖项列表
|
||||||
|
- 使用说明
|
||||||
|
- API 接口文档
|
||||||
|
|
||||||
|
**适合**: 想要全面了解项目的开发者、用户和维护人员
|
||||||
|
|
||||||
|
### 2. [架构说明.md](./架构说明.md)
|
||||||
|
**详细的架构和代码结构说明**
|
||||||
|
|
||||||
|
包含内容:
|
||||||
|
- 架构概述
|
||||||
|
- 各层详细说明(View、ViewModel、Model、Service、Tools)
|
||||||
|
- 数据流程图
|
||||||
|
- 设计模式应用
|
||||||
|
- 并发处理机制
|
||||||
|
- 缓存机制
|
||||||
|
- 错误处理
|
||||||
|
- 资源管理
|
||||||
|
- 扩展性设计
|
||||||
|
- 性能优化建议
|
||||||
|
|
||||||
|
**适合**: 需要深入了解代码结构和架构设计的开发者
|
||||||
|
|
||||||
|
### 3. [快速开始.md](./快速开始.md)
|
||||||
|
**快速上手指南**
|
||||||
|
|
||||||
|
包含内容:
|
||||||
|
- 环境准备
|
||||||
|
- 编译和运行
|
||||||
|
- 功能使用步骤
|
||||||
|
- 常见问题解答
|
||||||
|
- 性能优化建议
|
||||||
|
- 注意事项
|
||||||
|
|
||||||
|
**适合**: 初次使用项目的用户和开发者
|
||||||
|
|
||||||
|
## 🚀 快速导航
|
||||||
|
|
||||||
|
### 我是新用户
|
||||||
|
👉 从 [快速开始.md](./快速开始.md) 开始,了解如何运行和使用程序
|
||||||
|
|
||||||
|
### 我是开发者
|
||||||
|
👉 先看 [项目文档.md](./项目文档.md) 了解整体情况,然后阅读 [架构说明.md](./架构说明.md) 深入理解代码
|
||||||
|
|
||||||
|
### 我需要了解某个功能
|
||||||
|
👉 查看 [项目文档.md](./项目文档.md) 中的"主要功能"章节
|
||||||
|
|
||||||
|
### 我需要修改代码
|
||||||
|
👉 阅读 [架构说明.md](./架构说明.md) 了解代码结构和设计模式
|
||||||
|
|
||||||
|
## 📋 文档更新记录
|
||||||
|
|
||||||
|
- 2025-01-01: 初始文档创建
|
||||||
|
- 项目文档.md
|
||||||
|
- 架构说明.md
|
||||||
|
- 快速开始.md
|
||||||
|
|
||||||
|
## 💡 使用建议
|
||||||
|
|
||||||
|
1. **首次使用**: 按照 [快速开始.md](./快速开始.md) 的步骤操作
|
||||||
|
2. **深入了解**: 阅读 [项目文档.md](./项目文档.md) 了解所有功能
|
||||||
|
3. **开发维护**: 参考 [架构说明.md](./架构说明.md) 理解代码结构
|
||||||
|
|
||||||
|
## 📝 文档维护
|
||||||
|
|
||||||
|
如果发现文档有误或需要更新,请:
|
||||||
|
1. 检查代码是否已变更
|
||||||
|
2. 更新相应的文档章节
|
||||||
|
3. 更新本文档的更新记录
|
||||||
|
|
||||||
|
## 🔗 相关资源
|
||||||
|
|
||||||
|
- 项目根目录: [../README.md](../README.md)
|
||||||
|
- 源代码: 项目根目录下的各个文件夹
|
||||||
|
- 日志文件: `bin/Debug/net8.0-windows/Log/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**: 所有文档均使用 Markdown 格式编写,可以使用任何 Markdown 阅读器查看。
|
||||||
|
|
||||||
244
docs/快速开始.md
Normal file
244
docs/快速开始.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# VideoConcat 快速开始指南
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
- Windows 10/11 或更高版本
|
||||||
|
- .NET 8.0 Runtime(如果未安装,程序会提示下载)
|
||||||
|
|
||||||
|
### 检查环境
|
||||||
|
```powershell
|
||||||
|
# 检查 .NET 版本
|
||||||
|
dotnet --version
|
||||||
|
```
|
||||||
|
|
||||||
|
如果未安装 .NET 8.0,请访问 [.NET 下载页面](https://dotnet.microsoft.com/download) 下载安装。
|
||||||
|
|
||||||
|
## 获取项目
|
||||||
|
|
||||||
|
### 方式一:克隆仓库
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd VideoConcat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:下载源码
|
||||||
|
下载项目压缩包并解压到本地目录。
|
||||||
|
|
||||||
|
## 编译项目
|
||||||
|
|
||||||
|
### 使用 Visual Studio
|
||||||
|
1. 打开 `VideoConcat.sln`
|
||||||
|
2. 选择 Release 或 Debug 配置
|
||||||
|
3. 点击"生成" → "生成解决方案" (Ctrl+Shift+B)
|
||||||
|
|
||||||
|
### 使用命令行
|
||||||
|
```powershell
|
||||||
|
# Debug 编译
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Release 编译
|
||||||
|
dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行项目
|
||||||
|
|
||||||
|
### 方式一:Visual Studio
|
||||||
|
1. 按 F5 或点击"启动"按钮
|
||||||
|
2. 程序会自动编译并运行
|
||||||
|
|
||||||
|
### 方式二:命令行
|
||||||
|
```powershell
|
||||||
|
# Debug 运行
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# 或直接运行可执行文件
|
||||||
|
.\bin\Debug\net8.0-windows\VideoConcat.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:Release 版本
|
||||||
|
```powershell
|
||||||
|
.\bin\Release\net8.0-windows\VideoConcat.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能使用
|
||||||
|
|
||||||
|
### 1. 视频拼接功能
|
||||||
|
|
||||||
|
#### 准备工作
|
||||||
|
准备视频文件,建议的文件夹结构:
|
||||||
|
```
|
||||||
|
视频文件夹/
|
||||||
|
├── 文件夹1/
|
||||||
|
│ ├── 视频1.mp4
|
||||||
|
│ ├── 视频2.mp4
|
||||||
|
│ └── 视频3.mp4
|
||||||
|
├── 文件夹2/
|
||||||
|
│ ├── 视频A.mp4
|
||||||
|
│ └── 视频B.mp4
|
||||||
|
└── output/ (自动创建,用于存放输出文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 操作步骤
|
||||||
|
1. **启动程序**
|
||||||
|
- 运行 VideoConcat.exe
|
||||||
|
|
||||||
|
2. **选择功能**
|
||||||
|
- 在主界面选择"视频"标签
|
||||||
|
|
||||||
|
3. **选择文件夹**
|
||||||
|
- 点击"选择文件夹"按钮
|
||||||
|
- 选择包含视频子文件夹的目录(不是选择单个视频文件)
|
||||||
|
|
||||||
|
4. **选择拼接模式**
|
||||||
|
- **组合拼接模式**: 从每个文件夹随机选择视频进行组合
|
||||||
|
- 例如:文件夹1有3个视频,文件夹2有2个视频
|
||||||
|
- 可以生成 3×2=6 种组合
|
||||||
|
- **顺序拼接模式**: 按索引顺序从每个文件夹选择对应位置的视频
|
||||||
|
- 要求所有文件夹的视频数量相同
|
||||||
|
- 例如:每个文件夹都有3个视频,会生成3个组合
|
||||||
|
|
||||||
|
5. **设置数量**
|
||||||
|
- 在"数量"输入框中输入要生成的视频数量
|
||||||
|
- 数量不能超过最大可生成数量
|
||||||
|
|
||||||
|
6. **(可选)添加审核图片**
|
||||||
|
- 点击"选择审核图片"按钮
|
||||||
|
- 选择要添加为水印的图片文件
|
||||||
|
- 图片会叠加在视频底部
|
||||||
|
|
||||||
|
7. **开始拼接**
|
||||||
|
- 点击"开始拼接"按钮
|
||||||
|
- 程序会显示处理进度
|
||||||
|
- 完成后在 `output` 文件夹查看结果
|
||||||
|
|
||||||
|
#### 输出结果
|
||||||
|
- 输出位置: `{选择的文件夹}/output/`
|
||||||
|
- 文件命名: `yyyyMMddHHmmss{随机数}.mp4`
|
||||||
|
- 如果添加了审核图片,会生成两个文件:
|
||||||
|
- 原始拼接文件
|
||||||
|
- 带水印的文件(文件名不同)
|
||||||
|
|
||||||
|
### 2. 视频抽帧功能
|
||||||
|
|
||||||
|
#### 准备工作
|
||||||
|
准备要处理的视频文件:
|
||||||
|
```
|
||||||
|
视频文件夹/
|
||||||
|
├── 视频1.mp4
|
||||||
|
├── 视频2.mp4
|
||||||
|
└── 视频3.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 操作步骤
|
||||||
|
1. **选择功能**
|
||||||
|
- 在主界面选择"抽帧"标签
|
||||||
|
|
||||||
|
2. **选择文件夹**
|
||||||
|
- 点击"选择文件夹"按钮
|
||||||
|
- 选择包含视频文件的目录
|
||||||
|
|
||||||
|
3. **开始抽帧**
|
||||||
|
- 点击"开始抽帧"按钮
|
||||||
|
- 程序会随机删除每个视频中的一帧
|
||||||
|
- 处理完成后在 `out` 文件夹查看结果
|
||||||
|
|
||||||
|
#### 输出结果
|
||||||
|
- 输出位置: `{选择的文件夹}/out/`
|
||||||
|
- 文件命名: `{随机数}{原文件名}`
|
||||||
|
|
||||||
|
### 3. 视频元数据修改功能
|
||||||
|
|
||||||
|
#### 操作步骤
|
||||||
|
1. **选择功能**
|
||||||
|
- 在主界面选择"抽帧"标签
|
||||||
|
|
||||||
|
2. **选择文件夹**
|
||||||
|
- 点击"选择文件夹"按钮
|
||||||
|
- 选择包含视频文件的目录
|
||||||
|
|
||||||
|
3. **开始修改**
|
||||||
|
- 点击"开始修改"按钮
|
||||||
|
- 程序会修改视频的元数据(添加注释)
|
||||||
|
- 这会改变文件的 MD5 值,但不改变视频内容
|
||||||
|
|
||||||
|
#### 输出结果
|
||||||
|
- 输出位置: `{选择的文件夹}/out/`
|
||||||
|
- 文件命名: `modify{随机数}{原文件名}`
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 程序无法启动
|
||||||
|
**A**: 检查是否安装了 .NET 8.0 Runtime。如果未安装,程序会提示下载链接。
|
||||||
|
|
||||||
|
### Q2: 视频转换失败
|
||||||
|
**A**:
|
||||||
|
- 检查视频文件是否损坏
|
||||||
|
- 检查视频格式是否支持(主要支持 MP4)
|
||||||
|
- 查看日志文件了解详细错误信息
|
||||||
|
|
||||||
|
### Q3: 拼接后的视频没有声音
|
||||||
|
**A**:
|
||||||
|
- 检查原始视频是否有音频轨道
|
||||||
|
- 某些编码格式可能需要重新编码音频
|
||||||
|
|
||||||
|
### Q4: 处理速度很慢
|
||||||
|
**A**:
|
||||||
|
- 视频处理是 CPU 密集型操作,大文件需要较长时间
|
||||||
|
- 可以关闭其他占用 CPU 的程序
|
||||||
|
- 检查磁盘空间是否充足
|
||||||
|
|
||||||
|
### Q5: 临时文件占用空间
|
||||||
|
**A**:
|
||||||
|
- 临时文件存储在系统临时目录
|
||||||
|
- 程序处理完成后会自动清理
|
||||||
|
- 如果程序异常退出,可能需要手动清理临时文件
|
||||||
|
|
||||||
|
### Q6: 顺序拼接模式提示错误
|
||||||
|
**A**:
|
||||||
|
- 确保所有文件夹中的视频数量相同
|
||||||
|
- 检查文件夹名称是否正确
|
||||||
|
|
||||||
|
### Q7: 如何查看日志
|
||||||
|
**A**:
|
||||||
|
- 日志文件位置: `bin/Debug/net8.0-windows/Log/`
|
||||||
|
- 日志文件命名: `log{日期}.log`,例如 `log20251019.log`
|
||||||
|
- 使用文本编辑器打开查看
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
1. **使用 SSD 硬盘**: 视频处理涉及大量磁盘 I/O,SSD 可以显著提升速度
|
||||||
|
|
||||||
|
2. **关闭杀毒软件实时扫描**: 临时文件频繁创建和删除可能触发扫描
|
||||||
|
|
||||||
|
3. **确保足够内存**: 建议至少 8GB RAM
|
||||||
|
|
||||||
|
4. **关闭其他程序**: 释放 CPU 和内存资源
|
||||||
|
|
||||||
|
5. **批量处理**: 一次处理多个文件比逐个处理更高效(程序已实现)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **备份重要文件**: 虽然程序不会修改原始文件,但建议先备份
|
||||||
|
|
||||||
|
2. **磁盘空间**: 确保有足够的磁盘空间存储输出文件和临时文件
|
||||||
|
|
||||||
|
3. **文件路径**: 避免使用包含特殊字符的文件路径
|
||||||
|
|
||||||
|
4. **处理时间**: 视频处理需要时间,请耐心等待,不要强制关闭程序
|
||||||
|
|
||||||
|
5. **HEVC 编码**: 抽帧功能会自动将 HEVC 编码转换为 H.264,这需要额外时间
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如遇到问题,可以:
|
||||||
|
1. 查看日志文件了解详细错误信息
|
||||||
|
2. 检查视频文件是否正常
|
||||||
|
3. 确认系统环境是否符合要求
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 阅读 [项目文档.md](./项目文档.md) 了解详细功能
|
||||||
|
- 阅读 [架构说明.md](./架构说明.md) 了解代码结构
|
||||||
|
- 查看源代码了解实现细节
|
||||||
|
|
||||||
509
docs/架构说明.md
Normal file
509
docs/架构说明.md
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
# VideoConcat 架构说明文档
|
||||||
|
|
||||||
|
## 架构概述
|
||||||
|
|
||||||
|
VideoConcat 采用经典的 MVVM(Model-View-ViewModel)架构模式,结合服务层和工具层,实现清晰的职责分离。
|
||||||
|
|
||||||
|
## 架构层次
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ View Layer │ (Views/)
|
||||||
|
│ (XAML + Code-Behind) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ Data Binding
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ ViewModel Layer │ (ViewModels/)
|
||||||
|
│ (业务逻辑 + 命令处理) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ Model Layer │ (Models/)
|
||||||
|
│ (数据模型 + 业务状态) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ Service Layer │ (Services/)
|
||||||
|
│ (核心业务逻辑处理) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ Tools Layer │ (Common/Tools/)
|
||||||
|
│ (工具类 + 辅助功能) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各层详细说明
|
||||||
|
|
||||||
|
### 1. View Layer(视图层)
|
||||||
|
|
||||||
|
**位置**: `Views/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 定义用户界面(XAML)
|
||||||
|
- 处理 UI 交互事件
|
||||||
|
- 数据绑定到 ViewModel
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### MainWindow.xaml / MainWindow.xaml.cs
|
||||||
|
- 主窗口,包含导航功能
|
||||||
|
- 通过 RadioButton 切换不同功能视图
|
||||||
|
- 动态加载 VideoWindow 或 ExtractWindow
|
||||||
|
|
||||||
|
**关键代码**:
|
||||||
|
```csharp
|
||||||
|
private void RadioButton_Checked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (radioButton.Name == "extract")
|
||||||
|
{
|
||||||
|
var existingView = new ExtractWindow();
|
||||||
|
AddViewToColumn(mainGrid, existingView);
|
||||||
|
}
|
||||||
|
if (radioButton.Name == "video")
|
||||||
|
{
|
||||||
|
var existingView = new VideoWindow();
|
||||||
|
AddViewToColumn(mainGrid, existingView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### VideoWindow.xaml / VideoWindow.xaml.cs
|
||||||
|
- 视频拼接功能界面
|
||||||
|
- 显示文件夹列表、视频列表、拼接结果
|
||||||
|
|
||||||
|
#### ExtractWindow.xaml / ExtractWindow.xaml.cs
|
||||||
|
- 视频抽帧和元数据修改功能界面
|
||||||
|
- 显示视频文件列表和处理结果
|
||||||
|
|
||||||
|
#### LoginWindow.xaml / LoginWindow.xaml.cs
|
||||||
|
- 用户登录界面
|
||||||
|
|
||||||
|
### 2. ViewModel Layer(视图模型层)
|
||||||
|
|
||||||
|
**位置**: `ViewModels/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 处理用户交互逻辑
|
||||||
|
- 协调 Model 和 Service
|
||||||
|
- 实现命令模式
|
||||||
|
- 管理 UI 状态
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### VideoViewModel.cs
|
||||||
|
- 视频拼接功能的视图模型
|
||||||
|
- 实现文件夹选择、视频组合生成、拼接执行等逻辑
|
||||||
|
|
||||||
|
**核心流程**:
|
||||||
|
1. 用户选择文件夹 → `ListFolder()` 扫描视频文件
|
||||||
|
2. 生成视频组合 → `GenerateCombinations()` 或顺序组合
|
||||||
|
3. 转换视频格式 → 并发转换为 TS 格式
|
||||||
|
4. 拼接视频 → 使用 FFmpeg concat
|
||||||
|
5. 添加水印(可选)→ 图片叠加
|
||||||
|
|
||||||
|
**关键特性**:
|
||||||
|
- 使用 `SemaphoreSlim` 控制并发数量(10个线程)
|
||||||
|
- 使用 `Task.Run` 进行异步处理
|
||||||
|
- 通过 `Dispatcher.Invoke` 更新 UI 线程
|
||||||
|
|
||||||
|
#### ExtractWindowViewModel.cs
|
||||||
|
- 视频抽帧和元数据修改的视图模型
|
||||||
|
- 实现批量处理逻辑
|
||||||
|
|
||||||
|
#### MainWindowViewModel.cs
|
||||||
|
- 主窗口视图模型(当前较简单)
|
||||||
|
|
||||||
|
### 3. Model Layer(模型层)
|
||||||
|
|
||||||
|
**位置**: `Models/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 定义数据模型
|
||||||
|
- 实现 INotifyPropertyChanged 接口
|
||||||
|
- 管理业务状态
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### VideoModel.cs
|
||||||
|
- 视频拼接功能的数据模型
|
||||||
|
- 包含文件夹信息、视频列表、拼接结果等
|
||||||
|
|
||||||
|
**关键属性**:
|
||||||
|
- `FolderInfos`: 文件夹信息集合
|
||||||
|
- `ConcatVideos`: 拼接结果集合
|
||||||
|
- `IsJoinType1Selected` / `IsJoinType2Selected`: 拼接模式
|
||||||
|
- `CanStart`: 是否可以开始(基于数量和状态)
|
||||||
|
|
||||||
|
**状态管理**:
|
||||||
|
```csharp
|
||||||
|
public void SetCanStart()
|
||||||
|
{
|
||||||
|
if (Num > 0 && Num <= MaxNum && IsStart == false)
|
||||||
|
{
|
||||||
|
CanStart = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CanStart = false;
|
||||||
|
}
|
||||||
|
IsCanOperate = !IsStart;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ExtractWindowModel.cs
|
||||||
|
- 抽帧功能的数据模型
|
||||||
|
- 包含视频文件列表和处理状态
|
||||||
|
|
||||||
|
#### MainWindowModel.cs
|
||||||
|
- 主窗口数据模型
|
||||||
|
|
||||||
|
### 4. Service Layer(服务层)
|
||||||
|
|
||||||
|
**位置**: `Services/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 实现核心业务逻辑
|
||||||
|
- 封装视频处理操作
|
||||||
|
- 与 FFmpeg 交互
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### VideoService.cs
|
||||||
|
- 视频服务,提供视频转换和拼接功能
|
||||||
|
|
||||||
|
**核心方法**:
|
||||||
|
|
||||||
|
1. **ConvertVideos**: 视频格式转换
|
||||||
|
```csharp
|
||||||
|
public static string ConvertVideos(string videoPath)
|
||||||
|
{
|
||||||
|
// 1. 分析视频信息
|
||||||
|
var video = FFProbe.Analyse(videoPath);
|
||||||
|
|
||||||
|
// 2. 生成 MD5 缓存文件名
|
||||||
|
string _tempMd5Name = GetLargeFileMD5(videoPath);
|
||||||
|
var destinationPath = Path.Combine(Path.GetTempPath(), $"{_tempMd5Name}{FileExtension.Ts}");
|
||||||
|
|
||||||
|
// 3. 检查缓存
|
||||||
|
if (File.Exists(destinationPath))
|
||||||
|
return destinationPath;
|
||||||
|
|
||||||
|
// 4. 转换视频
|
||||||
|
FFMpeg.Convert(videoPath, destinationPath, VideoType.Ts);
|
||||||
|
|
||||||
|
return destinationPath;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **JoinVideos**: 视频拼接
|
||||||
|
- 使用 `FFMpegArguments.FromConcatInput()` 进行拼接
|
||||||
|
- 支持音频重新编码
|
||||||
|
- 可选添加图片水印
|
||||||
|
|
||||||
|
3. **GetLargeFileMD5**: 大文件 MD5 计算
|
||||||
|
- 使用流式读取,避免内存溢出
|
||||||
|
- 8KB 缓冲区优化
|
||||||
|
|
||||||
|
#### VideoProcess.cs
|
||||||
|
- 视频处理服务,提供抽帧、裁剪、元数据修改等功能
|
||||||
|
|
||||||
|
**核心方法**:
|
||||||
|
|
||||||
|
1. **RemoveFrameRandomeAsync**: 异步删除随机帧
|
||||||
|
- 处理 HEVC 编码(自动转换)
|
||||||
|
- 分割视频并重新合并
|
||||||
|
|
||||||
|
2. **ModifyByMetadata**: 修改元数据
|
||||||
|
- 添加唯一注释改变 MD5
|
||||||
|
- 使用流复制,不重新编码
|
||||||
|
|
||||||
|
3. **SubVideo / SubAudio**: 视频/音频裁剪
|
||||||
|
|
||||||
|
#### BaseService.cs
|
||||||
|
- 基础服务类(当前为空,可扩展)
|
||||||
|
|
||||||
|
### 5. Tools Layer(工具层)
|
||||||
|
|
||||||
|
**位置**: `Common/Tools/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 提供通用工具方法
|
||||||
|
- 封装第三方库调用
|
||||||
|
- 辅助功能实现
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### VideoCombine.cs
|
||||||
|
- 视频组合工具
|
||||||
|
- 提供组合生成和格式转换功能
|
||||||
|
|
||||||
|
**核心方法**:
|
||||||
|
```csharp
|
||||||
|
public static void GenerateCombinations(
|
||||||
|
List<List<string>> videoLists,
|
||||||
|
int index,
|
||||||
|
List<string> currentCombination,
|
||||||
|
List<List<string>> result)
|
||||||
|
{
|
||||||
|
// 递归生成所有可能的视频组合(笛卡尔积)
|
||||||
|
if (index == videoLists.Count)
|
||||||
|
{
|
||||||
|
result.Add([.. currentCombination]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string video in videoLists[index])
|
||||||
|
{
|
||||||
|
currentCombination.Add(video);
|
||||||
|
GenerateCombinations(videoLists, index + 1, currentCombination, result);
|
||||||
|
currentCombination.RemoveAt(currentCombination.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LogUtils.cs
|
||||||
|
- 日志工具类
|
||||||
|
- 基于 log4net 封装
|
||||||
|
- 提供 Info、Debug、Error、Warn、Fatal 等方法
|
||||||
|
|
||||||
|
#### HttpUtils.cs
|
||||||
|
- HTTP 请求工具
|
||||||
|
- 封装 HttpClient
|
||||||
|
- 提供 GetAsync 和 PostAsync 方法
|
||||||
|
|
||||||
|
#### Config.cs
|
||||||
|
- 配置管理工具
|
||||||
|
- 读取和更新 App.config 配置
|
||||||
|
|
||||||
|
### 6. API Layer(API层)
|
||||||
|
|
||||||
|
**位置**: `Common/Api/`
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 定义 API 接口
|
||||||
|
- 封装 HTTP 请求
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
#### SystemApi.cs
|
||||||
|
- 系统 API
|
||||||
|
- 提供登录接口
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
### 视频拼接流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户操作
|
||||||
|
↓
|
||||||
|
VideoWindow (View)
|
||||||
|
↓ (命令绑定)
|
||||||
|
VideoViewModel
|
||||||
|
↓
|
||||||
|
1. ListFolder() → 扫描文件夹
|
||||||
|
↓
|
||||||
|
2. GenerateCombinations() → 生成组合
|
||||||
|
↓
|
||||||
|
3. ConvertVideos() → 转换格式 (并发)
|
||||||
|
↓
|
||||||
|
4. JoinVideos() → 拼接视频 (并发)
|
||||||
|
↓
|
||||||
|
5. 更新 VideoModel.ConcatVideos
|
||||||
|
↓
|
||||||
|
UI 自动更新 (数据绑定)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 视频抽帧流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户操作
|
||||||
|
↓
|
||||||
|
ExtractWindow (View)
|
||||||
|
↓ (命令绑定)
|
||||||
|
ExtractWindowViewModel
|
||||||
|
↓
|
||||||
|
1. ListFolder() → 扫描视频文件
|
||||||
|
↓
|
||||||
|
2. RemoveFrameRandomeAsync() → 抽帧处理 (并发)
|
||||||
|
↓
|
||||||
|
3. VideoProcess.RemoveFrameRandomeAsync()
|
||||||
|
↓
|
||||||
|
4. 输出到 out 文件夹
|
||||||
|
↓
|
||||||
|
UI 更新状态
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计模式
|
||||||
|
|
||||||
|
### 1. MVVM 模式
|
||||||
|
- **Model**: 数据模型和业务状态
|
||||||
|
- **View**: XAML 界面
|
||||||
|
- **ViewModel**: 连接 View 和 Model
|
||||||
|
|
||||||
|
### 2. 命令模式
|
||||||
|
- 自定义 `Command` 类实现 `ICommand` 接口
|
||||||
|
- 将 UI 操作封装为命令
|
||||||
|
|
||||||
|
### 3. 观察者模式
|
||||||
|
- 使用 `INotifyPropertyChanged` 实现属性变更通知
|
||||||
|
- 使用 `ObservableCollection` 实现集合变更通知
|
||||||
|
|
||||||
|
### 4. 单例模式
|
||||||
|
- `LogUtils` 使用静态方法,类似单例
|
||||||
|
|
||||||
|
## 并发处理
|
||||||
|
|
||||||
|
### SemaphoreSlim 控制并发
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SemaphoreSlim semaphore = new(10); // 限制10个并发
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
await semaphore.WaitAsync();
|
||||||
|
var task = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 处理逻辑
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 异步处理
|
||||||
|
|
||||||
|
- 使用 `async/await` 进行异步操作
|
||||||
|
- 使用 `Task.Run` 在后台线程执行耗时操作
|
||||||
|
- 使用 `Dispatcher.Invoke` 更新 UI 线程
|
||||||
|
|
||||||
|
## 缓存机制
|
||||||
|
|
||||||
|
### 视频转换缓存
|
||||||
|
|
||||||
|
基于 MD5 的文件名缓存:
|
||||||
|
```csharp
|
||||||
|
string _tempMd5Name = GetLargeFileMD5(videoPath);
|
||||||
|
var destinationPath = Path.Combine(Path.GetTempPath(), $"{_tempMd5Name}{FileExtension.Ts}");
|
||||||
|
|
||||||
|
if (File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
return destinationPath; // 直接返回缓存文件
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- 避免重复转换相同文件
|
||||||
|
- 提高处理速度
|
||||||
|
- 减少 CPU 和磁盘 I/O
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 异常捕获
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 主要处理逻辑
|
||||||
|
FFMpeg.Convert(videoPath, destinationPath, VideoType.Ts);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 备用方案
|
||||||
|
LogUtils.Info("视频转换失败!尝试另外一种转换");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FFMpegArguments
|
||||||
|
.FromFileInput(videoPath)
|
||||||
|
.OutputToFile(destinationPath, true, o => o
|
||||||
|
.WithVideoCodec("libx264")
|
||||||
|
// ... 其他参数
|
||||||
|
)
|
||||||
|
.ProcessSynchronously(true);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
LogUtils.Error($"{videoPath} 转换失败", ex);
|
||||||
|
LogUtils.Error($"{videoPath} 转换再次失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志记录
|
||||||
|
|
||||||
|
- 所有错误都记录到日志文件
|
||||||
|
- 使用 log4net 进行日志管理
|
||||||
|
- 日志文件按日期命名
|
||||||
|
|
||||||
|
## 资源管理
|
||||||
|
|
||||||
|
### 临时文件清理
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static void Cleanup(List<string> pathList)
|
||||||
|
{
|
||||||
|
foreach (var path in pathList)
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件流管理
|
||||||
|
|
||||||
|
使用 `using` 语句确保资源释放:
|
||||||
|
```csharp
|
||||||
|
using var md5 = MD5.Create();
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
// ... 处理逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展性设计
|
||||||
|
|
||||||
|
### 1. 服务层扩展
|
||||||
|
- 可以添加新的 Service 类
|
||||||
|
- 继承 BaseService(如需要)
|
||||||
|
|
||||||
|
### 2. 工具类扩展
|
||||||
|
- 在 `Common/Tools/` 中添加新工具类
|
||||||
|
- 保持工具类的静态方法设计
|
||||||
|
|
||||||
|
### 3. API 扩展
|
||||||
|
- 在 `Common/Api/` 中添加新的 API 类
|
||||||
|
- 使用 HttpUtils 进行请求
|
||||||
|
|
||||||
|
### 4. 功能扩展
|
||||||
|
- 添加新的 View 和 ViewModel
|
||||||
|
- 在主窗口中添加导航入口
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
1. **并发控制**: 已使用 SemaphoreSlim 限制并发
|
||||||
|
2. **缓存机制**: 已实现基于 MD5 的转换缓存
|
||||||
|
3. **异步处理**: 已使用 async/await 避免阻塞
|
||||||
|
4. **流式处理**: 大文件使用流式读取
|
||||||
|
|
||||||
|
**可进一步优化**:
|
||||||
|
- 添加进度报告机制
|
||||||
|
- 优化大文件 MD5 计算(可考虑分块并行)
|
||||||
|
- 添加视频预览功能(减少不必要的处理)
|
||||||
|
- 实现断点续传(大文件处理)
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **单元测试**: 为 Service 和 Tools 层添加单元测试
|
||||||
|
2. **集成测试**: 测试完整的视频处理流程
|
||||||
|
3. **性能测试**: 测试并发处理和大量文件处理
|
||||||
|
4. **异常测试**: 测试各种异常情况(文件不存在、格式不支持等)
|
||||||
|
|
||||||
402
docs/项目文档.md
Normal file
402
docs/项目文档.md
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
# VideoConcat 项目文档
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
VideoConcat 是一个基于 WPF 开发的视频处理应用程序,主要用于视频拼接、视频抽帧和视频元数据修改等功能。项目采用 MVVM 架构模式,使用 FFMpegCore 库进行视频处理操作。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: .NET 8.0
|
||||||
|
- **UI框架**: WPF (Windows Presentation Foundation)
|
||||||
|
- **视频处理**: FFMpegCore 5.2.0
|
||||||
|
- **日志**: log4net 3.1.0
|
||||||
|
- **UI组件库**:
|
||||||
|
- MaterialDesignThemes.Wpf
|
||||||
|
- MahApps.Metro.IconPacks
|
||||||
|
- LiveCharts.Wpf (图表库)
|
||||||
|
- **依赖注入**: Microsoft.Extensions.DependencyInjection
|
||||||
|
- **JSON处理**: Newtonsoft.Json 13.0.3
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
VideoConcat/
|
||||||
|
├── Common/ # 公共模块
|
||||||
|
│ ├── Api/ # API接口
|
||||||
|
│ │ ├── Base/ # 基础API
|
||||||
|
│ │ │ ├── SystemApi.cs # 系统API(登录等)
|
||||||
|
│ │ │ └── LoginResponse.cs
|
||||||
|
│ │ └── Common/ # 通用API
|
||||||
|
│ └── Tools/ # 工具类
|
||||||
|
│ ├── Config.cs # 配置管理
|
||||||
|
│ ├── HttpUtils.cs # HTTP请求封装
|
||||||
|
│ ├── LogUtils.cs # 日志工具
|
||||||
|
│ └── VideoCombine.cs # 视频组合工具
|
||||||
|
├── Conversions/ # 转换器
|
||||||
|
│ └── EnumToBooleanConverter.cs
|
||||||
|
├── Helpers/ # 辅助类
|
||||||
|
│ └── PasswordBoxHelper.cs
|
||||||
|
├── Models/ # 数据模型
|
||||||
|
│ ├── ExtractWindowModel.cs # 抽帧窗口模型
|
||||||
|
│ ├── MainWindowModel.cs # 主窗口模型
|
||||||
|
│ └── VideoModel.cs # 视频模型
|
||||||
|
├── Services/ # 服务层
|
||||||
|
│ ├── BaseService.cs # 基础服务
|
||||||
|
│ └── Video/ # 视频服务
|
||||||
|
│ ├── VideoProcess.cs # 视频处理服务
|
||||||
|
│ └── VideoService.cs # 视频服务
|
||||||
|
├── ViewModels/ # 视图模型
|
||||||
|
│ ├── ExtractWindowViewModel.cs
|
||||||
|
│ ├── MainWindowViewModel.cs
|
||||||
|
│ └── VideoViewModel.cs
|
||||||
|
├── Views/ # 视图
|
||||||
|
│ ├── ExtractWindow.xaml # 抽帧窗口
|
||||||
|
│ ├── LoginWindow.xaml # 登录窗口
|
||||||
|
│ ├── MainWindow.xaml # 主窗口
|
||||||
|
│ └── VideoWindow.xaml # 视频窗口
|
||||||
|
├── App.xaml # 应用程序入口
|
||||||
|
├── VideoConcat.csproj # 项目文件
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### 1. 视频拼接功能
|
||||||
|
|
||||||
|
支持两种拼接模式:
|
||||||
|
|
||||||
|
#### 模式一:组合拼接(IsJoinType1Selected)
|
||||||
|
- 从多个文件夹中选择视频文件
|
||||||
|
- 生成所有可能的视频组合
|
||||||
|
- 随机选择指定数量的组合进行拼接
|
||||||
|
- 支持添加审核图片水印
|
||||||
|
|
||||||
|
#### 模式二:顺序拼接(IsJoinType2Selected)
|
||||||
|
- 按照文件夹顺序,从每个文件夹中选择对应位置的视频
|
||||||
|
- 要求所有文件夹中的视频数量相同
|
||||||
|
- 按索引顺序拼接视频
|
||||||
|
|
||||||
|
**拼接流程**:
|
||||||
|
1. 选择包含视频文件夹的目录
|
||||||
|
2. 系统自动扫描各文件夹中的 MP4 文件
|
||||||
|
3. 根据选择的模式生成视频组合
|
||||||
|
4. 将视频转换为 TS 格式(临时文件,基于 MD5 缓存)
|
||||||
|
5. 使用 FFmpeg 进行视频拼接
|
||||||
|
6. 可选:添加审核图片水印
|
||||||
|
7. 输出到 `output` 文件夹
|
||||||
|
|
||||||
|
### 2. 视频抽帧功能
|
||||||
|
|
||||||
|
- 随机删除视频中的某一帧
|
||||||
|
- 支持 HEVC 编码格式(自动转换为 H.264)
|
||||||
|
- 保持视频和音频同步
|
||||||
|
- 批量处理视频文件
|
||||||
|
|
||||||
|
**处理流程**:
|
||||||
|
1. 分析视频信息(时长、帧率等)
|
||||||
|
2. 随机选择要删除的帧(避开开头和结尾)
|
||||||
|
3. 将视频分割为两部分(删除帧前后)
|
||||||
|
4. 重新合并视频
|
||||||
|
5. 输出到 `out` 文件夹
|
||||||
|
|
||||||
|
### 3. 视频元数据修改
|
||||||
|
|
||||||
|
- 通过修改视频元数据中的注释信息改变文件 MD5
|
||||||
|
- 使用流复制模式,不重新编码视频
|
||||||
|
- 批量处理视频文件
|
||||||
|
|
||||||
|
### 4. 用户登录
|
||||||
|
|
||||||
|
- 支持用户登录验证
|
||||||
|
- 记录登录设备信息(机器名、用户名、IP地址)
|
||||||
|
- 与后端 API 交互进行身份验证
|
||||||
|
|
||||||
|
## 核心模块说明
|
||||||
|
|
||||||
|
### VideoService(视频服务)
|
||||||
|
|
||||||
|
**主要方法**:
|
||||||
|
|
||||||
|
- `ConvertVideos(string videoPath)`: 将视频文件转换为 TS 格式
|
||||||
|
- 使用 MD5 作为临时文件名,实现缓存机制
|
||||||
|
- 支持多种编码格式转换
|
||||||
|
- 失败时自动尝试备用转换方案
|
||||||
|
|
||||||
|
- `GetLargeFileMD5(string filePath)`: 计算大文件的 MD5 值
|
||||||
|
- 使用流式读取,支持大文件处理
|
||||||
|
- 8KB 缓冲区优化内存使用
|
||||||
|
|
||||||
|
- `JoinVideos(List<string> combination)`: 拼接视频列表
|
||||||
|
- 使用 FFmpeg concat 功能
|
||||||
|
- 支持音频重新编码(AAC,44.1kHz)
|
||||||
|
- 可选添加图片水印
|
||||||
|
|
||||||
|
- `Cleanup(List<string> pathList)`: 清理临时文件
|
||||||
|
|
||||||
|
### VideoProcess(视频处理)
|
||||||
|
|
||||||
|
**主要方法**:
|
||||||
|
|
||||||
|
- `RemoveFrameRandomeAsync(string inputPath, string outputPath)`: 异步删除随机帧
|
||||||
|
- 自动处理 HEVC 编码
|
||||||
|
- 保持音视频同步
|
||||||
|
|
||||||
|
- `ModifyByMetadata(string inputPath, string outputPath, string comment)`: 修改视频元数据
|
||||||
|
- 添加唯一注释信息
|
||||||
|
- 使用流复制,不重新编码
|
||||||
|
|
||||||
|
- `SubVideo(string inputPath, string outputPath, double startSec, double endSec)`: 视频裁剪
|
||||||
|
- `SubAudio(string inputPath, string outputPath, double startSec, double endSec)`: 音频裁剪
|
||||||
|
- `JoinVideo(string outPutPath, string[] videoParts)`: 视频合并
|
||||||
|
|
||||||
|
### VideoCombine(视频组合工具)
|
||||||
|
|
||||||
|
**主要方法**:
|
||||||
|
|
||||||
|
- `GenerateCombinations(List<List<string>> videoLists, int index, List<string> currentCombination, List<List<string>> result)`: 生成所有视频组合
|
||||||
|
- 递归算法生成笛卡尔积
|
||||||
|
- 支持多文件夹视频组合
|
||||||
|
|
||||||
|
- `ConvertVideos(string videoPath)`: 视频格式转换(与 VideoService 类似)
|
||||||
|
|
||||||
|
### LogUtils(日志工具)
|
||||||
|
|
||||||
|
基于 log4net 的日志封装,支持:
|
||||||
|
- Info: 信息日志
|
||||||
|
- Debug: 调试日志
|
||||||
|
- Error: 错误日志
|
||||||
|
- Warn: 警告日志
|
||||||
|
- Fatal: 致命错误日志
|
||||||
|
|
||||||
|
日志文件保存在 `bin/Debug/net8.0-windows/Log/` 目录下。
|
||||||
|
|
||||||
|
### HttpUtils(HTTP工具)
|
||||||
|
|
||||||
|
封装了 HTTP 请求功能:
|
||||||
|
- `GetAsync<T>(string url)`: GET 请求
|
||||||
|
- `PostAsync<T>(string url, object data)`: POST 请求
|
||||||
|
- 默认 BaseAddress: `https://admin.xiangbing.vip`
|
||||||
|
- 自动 JSON 序列化/反序列化
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### VideoModel(视频模型)
|
||||||
|
|
||||||
|
**主要属性**:
|
||||||
|
- `FolderPath`: 视频文件夹路径
|
||||||
|
- `FolderInfos`: 文件夹信息集合(包含视频文件列表)
|
||||||
|
- `ConcatVideos`: 拼接完成的视频列表
|
||||||
|
- `Num`: 需要拼接的视频数量
|
||||||
|
- `MaxNum`: 最大可拼接数量
|
||||||
|
- `AuditImagePath`: 审核图片路径
|
||||||
|
- `IsJoinType1Selected`: 组合拼接模式
|
||||||
|
- `IsJoinType2Selected`: 顺序拼接模式
|
||||||
|
- `CanStart`: 是否可以开始拼接
|
||||||
|
- `IsStart`: 是否正在处理
|
||||||
|
|
||||||
|
**FolderInfo 结构**:
|
||||||
|
```csharp
|
||||||
|
public struct FolderInfo
|
||||||
|
{
|
||||||
|
public DirectoryInfo DirectoryInfo;
|
||||||
|
public int Num; // 视频文件数量
|
||||||
|
public List<string> VideoPaths; // 视频文件路径列表
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ConcatVideo 类**:
|
||||||
|
```csharp
|
||||||
|
public class ConcatVideo
|
||||||
|
{
|
||||||
|
public int Index; // 索引
|
||||||
|
public string FileName; // 文件名
|
||||||
|
public string Size; // 文件大小
|
||||||
|
public int Seconds; // 时长(秒)
|
||||||
|
public string Status; // 状态
|
||||||
|
public string Progress; // 进度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ExtractWindowModel(抽帧窗口模型)
|
||||||
|
|
||||||
|
**主要属性**:
|
||||||
|
- `FolderPath`: 视频文件夹路径
|
||||||
|
- `videos`: 视频文件数组
|
||||||
|
- `CanExtractFrame`: 是否可以抽帧
|
||||||
|
- `CanModify`: 是否可以修改元数据
|
||||||
|
- `IsStart`: 是否正在处理
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### App.config
|
||||||
|
|
||||||
|
应用程序配置文件,用于存储应用设置。
|
||||||
|
|
||||||
|
### log4net.config
|
||||||
|
|
||||||
|
日志配置文件,定义日志输出格式和存储位置。
|
||||||
|
|
||||||
|
### app.manifest
|
||||||
|
|
||||||
|
应用程序清单文件,定义应用程序的权限和特性。
|
||||||
|
|
||||||
|
## 依赖项说明
|
||||||
|
|
||||||
|
| 包名 | 版本 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| FFMpegCore | 5.2.0 | FFmpeg 的 .NET 封装,用于视频处理 |
|
||||||
|
| LiveCharts | 0.9.7 | 图表库,用于数据可视化 |
|
||||||
|
| LiveCharts.Wpf | 0.9.7 | WPF 图表组件 |
|
||||||
|
| log4net | 3.1.0 | 日志框架 |
|
||||||
|
| MahApps.Metro.IconPacks | 6.0.0 | Metro 风格图标包 |
|
||||||
|
| MaterialDesignThemes.Wpf | - | Material Design 主题 |
|
||||||
|
| Microsoft.Extensions.Logging | 9.0.8 | 日志扩展 |
|
||||||
|
| Newtonsoft.Json | 13.0.3 | JSON 序列化库 |
|
||||||
|
| WPFDevelopers | 0.0.0.1 | WPF 开发工具库 |
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Windows 操作系统
|
||||||
|
- .NET 8.0 Runtime
|
||||||
|
- FFmpeg 可执行文件(已包含在 bin 目录中)
|
||||||
|
|
||||||
|
### 运行步骤
|
||||||
|
|
||||||
|
1. **编译项目**
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **运行程序**
|
||||||
|
```bash
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
或直接运行 `bin/Debug/net8.0-windows/VideoConcat.exe`
|
||||||
|
|
||||||
|
3. **使用视频拼接功能**
|
||||||
|
- 打开程序,选择"视频"标签
|
||||||
|
- 点击"选择文件夹",选择包含视频文件夹的目录
|
||||||
|
- 选择拼接模式(组合拼接或顺序拼接)
|
||||||
|
- 输入需要拼接的视频数量
|
||||||
|
- (可选)选择审核图片
|
||||||
|
- 点击"开始拼接"按钮
|
||||||
|
|
||||||
|
4. **使用视频抽帧功能**
|
||||||
|
- 选择"抽帧"标签
|
||||||
|
- 点击"选择文件夹",选择包含视频文件的目录
|
||||||
|
- 点击"开始抽帧"按钮
|
||||||
|
|
||||||
|
5. **使用元数据修改功能**
|
||||||
|
- 在"抽帧"标签中
|
||||||
|
- 选择视频文件夹
|
||||||
|
- 点击"开始修改"按钮
|
||||||
|
|
||||||
|
### 输出位置
|
||||||
|
|
||||||
|
- **拼接视频**: `{选择的文件夹}/output/`
|
||||||
|
- **抽帧视频**: `{选择的文件夹}/out/`
|
||||||
|
- **修改元数据视频**: `{选择的文件夹}/out/modify{原文件名}`
|
||||||
|
|
||||||
|
### 临时文件
|
||||||
|
|
||||||
|
- 视频转换后的 TS 文件存储在系统临时目录
|
||||||
|
- 文件名基于源文件的 MD5 值,实现缓存机制
|
||||||
|
- 处理完成后会自动清理临时文件
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
1. **并发处理**: 使用 `SemaphoreSlim` 限制并发数量(默认10个线程)
|
||||||
|
2. **文件缓存**: 基于 MD5 的临时文件缓存,避免重复转换
|
||||||
|
3. **流式处理**: 大文件 MD5 计算使用流式读取
|
||||||
|
4. **异步处理**: 视频处理操作使用异步方法,避免 UI 阻塞
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **视频格式**: 目前主要支持 MP4 格式
|
||||||
|
2. **文件路径**: 确保视频文件路径不包含特殊字符
|
||||||
|
3. **磁盘空间**: 视频处理需要足够的临时存储空间
|
||||||
|
4. **处理时间**: 视频处理时间取决于视频大小和数量
|
||||||
|
5. **HEVC 编码**: 抽帧功能会自动将 HEVC 编码转换为 H.264
|
||||||
|
|
||||||
|
## 日志说明
|
||||||
|
|
||||||
|
日志文件保存在 `bin/Debug/net8.0-windows/Log/` 目录下,按日期命名(如 `log20251019.log`)。
|
||||||
|
|
||||||
|
日志内容包括:
|
||||||
|
- 视频处理进度
|
||||||
|
- 错误信息
|
||||||
|
- 操作记录
|
||||||
|
- 性能统计
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 登录接口
|
||||||
|
|
||||||
|
**端点**: `/api/base/login`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Username": "用户名",
|
||||||
|
"Password": "密码",
|
||||||
|
"Platform": "pc",
|
||||||
|
"PcName": "机器名",
|
||||||
|
"PcUserName": "用户名",
|
||||||
|
"Ips": "IP地址"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应格式**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Code": 200,
|
||||||
|
"Msg": "消息",
|
||||||
|
"Data": {
|
||||||
|
// 用户信息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发说明
|
||||||
|
|
||||||
|
### MVVM 架构
|
||||||
|
|
||||||
|
项目采用 MVVM(Model-View-ViewModel)架构模式:
|
||||||
|
|
||||||
|
- **Model**: 数据模型和业务逻辑(Models 目录)
|
||||||
|
- **View**: 用户界面(Views 目录,XAML 文件)
|
||||||
|
- **ViewModel**: 视图模型,连接 View 和 Model(ViewModels 目录)
|
||||||
|
|
||||||
|
### 命令模式
|
||||||
|
|
||||||
|
使用自定义 `Command` 类实现命令模式,绑定到 UI 控件的命令属性。
|
||||||
|
|
||||||
|
### 数据绑定
|
||||||
|
|
||||||
|
使用 WPF 数据绑定机制,实现 UI 与数据的双向绑定。
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
1. 抽帧功能在某些情况下可能返回 false(代码中 `RemoveFrameRandomeAsync` 方法最后返回 false)
|
||||||
|
2. 顺序拼接模式要求所有文件夹视频数量相同,但错误提示信息不够明确
|
||||||
|
|
||||||
|
## 未来改进方向
|
||||||
|
|
||||||
|
1. 支持更多视频格式
|
||||||
|
2. 添加视频预览功能
|
||||||
|
3. 优化错误处理和用户提示
|
||||||
|
4. 添加进度条显示
|
||||||
|
5. 支持视频质量设置
|
||||||
|
6. 添加批量重命名功能
|
||||||
|
7. 支持视频裁剪功能(时间范围选择)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
(待补充)
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
(待补充)
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user