feat: update

This commit is contained in:
xiangbing 2026-01-01 15:39:54 +08:00
parent a28d3cac8e
commit a27e2eaaeb
15 changed files with 2302 additions and 87 deletions

View 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();
}
}
}

View File

@ -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;

View File

@ -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

View File

@ -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;
}
bool hasSubVideo2 = SubVideo(inputPath, videoPart2, randomFrame, totalDuration);
if (!hasSubVideo2)
{
LogUtils.Error($"裁剪第二部分视频失败:{videoPart2}");
return false; 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;
} }
return false; // 验证输出文件是否存在
if (File.Exists(outputPath))
{
LogUtils.Info($"抽帧成功:{outputPath}");
return true;
}
else
{
LogUtils.Error($"抽帧失败:输出文件不存在 - {outputPath}");
return false;
}
} }
catch (Exception ex) catch (Exception ex)
@ -107,12 +142,37 @@ namespace VideoConcat.Services.Video
} }
finally finally
{ {
string[] files = Directory.GetFiles(tempDir); // 清理临时目录
foreach (string file in files) try
{ {
File.Delete(file); 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
{
// 忽略清理错误
} }
File.Delete(tempDir);
} }
} }
@ -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") 去掉音频 // 计算时长
.ProcessSynchronously(); 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();
// 验证输出文件是否存在
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)

View File

@ -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 () =>
SemaphoreSlim semaphore = new(10); // Limit to 3 threads
List<Task> _tasks = [];
ExtractWindowModel.videos.ForEach(async (video) =>
{ {
await semaphore.WaitAsync(); // Wait when more than 3 threads are running int extractCount = ExtractWindowModel.ExtractCount;
var _task = Task.Run(async () => if (extractCount <= 0)
{ {
try ExtractWindowModel.Dispatcher.Invoke(() =>
{ {
// 实例化并调用 MessageBox.Show("请输入有效的生成个数大于0", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
var remover = new VideoProcess(); });
// 删除4秒处的帧需根据实际帧位置调整 return;
string _tmpPath = Path.GetDirectoryName(video) ?? ""; }
string _tmpFileName = $"{(new Random()).Next(10000, 99999)}{Path.GetFileName(video)}";
string outPath = Path.Combine(_tmpPath, "out"); ExtractWindowModel.Dispatcher.Invoke(() =>
if (!Path.Exists(outPath)) {
{ ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频...";
Directory.CreateDirectory(outPath); ExtractWindowModel.IsStart = true;
} ExtractWindowModel.IsCanOperate = false;
await VideoProcess.RemoveFrameRandomeAsync(video, $"{_tmpPath}\\out\\{_tmpFileName}");
}
finally
{
semaphore.Release(); // Work is done, signal to semaphore that more work is possible
}
}); });
_tasks.Add(_task);
});
Task.WhenAll(_tasks).ContinueWith((task) => SemaphoreSlim semaphore = new(10); // 限制并发数量
{
ExtractWindowModel.HelpInfo = "全部完成!"; List<Task> _tasks = [];
int totalTasks = ExtractWindowModel.videos.Length * extractCount;
int completedTasks = 0;
System.Collections.Concurrent.ConcurrentBag<string> errorMessages = new(); // 收集错误信息
// 对每个视频生成指定数量的抽帧视频
foreach (var video in ExtractWindowModel.videos)
{
for (int i = 1; i <= extractCount; i++)
{
int currentIndex = i; // 闭包变量
string currentVideo = video; // 闭包变量
await semaphore.WaitAsync();
var _task = Task.Run(async () =>
{
try
{
string _tmpPath = Path.GetDirectoryName(currentVideo) ?? "";
if (string.IsNullOrEmpty(_tmpPath))
{
LogUtils.Error($"无法获取视频目录:{currentVideo}");
Interlocked.Increment(ref completedTasks);
ExtractWindowModel.Dispatcher.Invoke(() =>
{
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
});
return;
}
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
{
semaphore.Release();
}
});
_tasks.Add(_task);
}
}
await Task.WhenAll(_tasks);
// 统计实际生成的文件数量
int actualFileCount = 0;
string outputDir = "";
if (ExtractWindowModel.videos.Length > 0)
{
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}");
}); });
} }
}, },

View File

@ -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(() =>
{ {
IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName); var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
FileInfo fileInfo = new(_outPutName); if (videoItem != null && File.Exists(_outPutName))
VideoModel.ConcatVideos.Add(new ConcatVideo()
{ {
Index = VideoModel.ConcatVideos.Count + 1, try
FileName = _tempFileName, {
Size = $"{fileInfo.Length / 1024 / 1024}MB", IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName);
Seconds = ((int)_mediaInfo.Duration.TotalSeconds), FileInfo fileInfo = new(_outPutName);
Status = _isSuccess ? "拼接成功" : "拼接失败",
Progress = "100%", 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)
{
videoItem.Status = "拼接失败";
videoItem.Progress = "失败";
LogUtils.Error($"分析视频信息失败:{_outPutName}", ex);
}
}
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 = [];
}); });
}); });

View File

@ -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>

View File

@ -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();
}
}
} }
} }

View 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>

View 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);
}
}
}

View File

@ -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
View 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
View 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/OSSD 可以显著提升速度
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
View File

@ -0,0 +1,509 @@
# VideoConcat 架构说明文档
## 架构概述
VideoConcat 采用经典的 MVVMModel-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 LayerAPI层
**位置**: `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
View 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 功能
- 支持音频重新编码AAC44.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/` 目录下。
### HttpUtilsHTTP工具
封装了 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 架构
项目采用 MVVMModel-View-ViewModel架构模式
- **Model**: 数据模型和业务逻辑Models 目录)
- **View**: 用户界面Views 目录XAML 文件)
- **ViewModel**: 视图模型,连接 View 和 ModelViewModels 目录)
### 命令模式
使用自定义 `Command` 类实现命令模式,绑定到 UI 控件的命令属性。
### 数据绑定
使用 WPF 数据绑定机制,实现 UI 与数据的双向绑定。
## 已知问题
1. 抽帧功能在某些情况下可能返回 false代码中 `RemoveFrameRandomeAsync` 方法最后返回 false
2. 顺序拼接模式要求所有文件夹视频数量相同,但错误提示信息不够明确
## 未来改进方向
1. 支持更多视频格式
2. 添加视频预览功能
3. 优化错误处理和用户提示
4. 添加进度条显示
5. 支持视频质量设置
6. 添加批量重命名功能
7. 支持视频裁剪功能(时间范围选择)
## 许可证
(待补充)
## 联系方式
(待补充)