986 lines
44 KiB
C#
986 lines
44 KiB
C#
using FFMpegCore;
|
||
using FFMpegCore.Enums;
|
||
using Standard;
|
||
using System;
|
||
using System.IO;
|
||
using System.Threading.Tasks;
|
||
using VideoConcat.Common.Tools;
|
||
using static System.Windows.Forms.DataFormats;
|
||
|
||
namespace VideoConcat.Services.Video
|
||
{
|
||
public class VideoProcess
|
||
{
|
||
/// <summary>
|
||
/// 在前30%、中间30%、后30%三个位置各随机删除一帧,确保通过广告平台相似度检查
|
||
/// </summary>
|
||
/// <param name="inputPath">输入文件路径</param>
|
||
/// <param name="outputPath">输出文件路径</param>
|
||
/// <returns>操作是否成功</returns>
|
||
public static async Task<bool> RemoveFrameRandomeAsync(string inputPath, string outputPath)
|
||
{
|
||
if (!File.Exists(inputPath))
|
||
{
|
||
LogUtils.Error($"输入文件不存在:{inputPath}");
|
||
return false;
|
||
}
|
||
|
||
// 创建临时目录
|
||
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||
try
|
||
{
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
// 1. 获取视频信息
|
||
IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||
var videoStream = mediaInfo.PrimaryVideoStream;
|
||
if (videoStream == null)
|
||
{
|
||
LogUtils.Error("没有找到视频流");
|
||
return false;
|
||
}
|
||
|
||
// 视频总时长(秒)
|
||
double totalDuration = mediaInfo.Duration.TotalSeconds;
|
||
double frameRate = videoStream.FrameRate;
|
||
|
||
// 确保视频时长足够(至少20秒)
|
||
if (totalDuration < 20)
|
||
{
|
||
LogUtils.Error($"视频时长太短({totalDuration:F2}秒),无法抽帧(需要至少20秒)");
|
||
return false;
|
||
}
|
||
|
||
// 计算总帧数
|
||
int totalFrames = (int)(totalDuration * frameRate);
|
||
if (totalFrames <= 0)
|
||
{
|
||
LogUtils.Error($"无法计算视频总帧数,帧率={frameRate}");
|
||
return false;
|
||
}
|
||
|
||
// 如果是 HEVC 编码,先转换为 H.264(select 过滤器在某些编码格式下可能不稳定)
|
||
bool isHevc = videoStream.CodecName == "hevc";
|
||
string workingInputPath = inputPath;
|
||
|
||
if (isHevc)
|
||
{
|
||
try
|
||
{
|
||
string videoConvert = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
LogUtils.Info($"检测到 HEVC 编码,先转换为 H.264:{inputPath} -> {videoConvert}");
|
||
|
||
await FFMpegArguments.FromFileInput(inputPath)
|
||
.OutputToFile(videoConvert, true, opt =>
|
||
opt.WithVideoCodec("libx264")
|
||
.WithAudioCodec("copy") // 复制音频流,不重新编码
|
||
).ProcessAsynchronously();
|
||
|
||
// 重新分析转换后的视频
|
||
mediaInfo = await FFProbe.AnalyseAsync(videoConvert);
|
||
videoStream = mediaInfo.PrimaryVideoStream;
|
||
if (videoStream == null)
|
||
{
|
||
LogUtils.Error("转换后没有找到视频流");
|
||
return false;
|
||
}
|
||
|
||
totalDuration = mediaInfo.Duration.TotalSeconds;
|
||
frameRate = videoStream.FrameRate;
|
||
totalFrames = (int)(totalDuration * frameRate);
|
||
workingInputPath = videoConvert;
|
||
|
||
LogUtils.Info($"HEVC 转换完成,新帧率={frameRate}, 总帧数={totalFrames}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Error("HEVC 转换失败", ex);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 2. 计算三个区域的范围(前30%、中间30%、后30%)
|
||
// 前30%:0% 到 30%
|
||
int front30StartFrame = 0;
|
||
int front30EndFrame = (int)(totalFrames * 0.30);
|
||
|
||
// 中间30%:35% 到 65%(避开边界)
|
||
int middle30StartFrame = (int)(totalFrames * 0.35);
|
||
int middle30EndFrame = (int)(totalFrames * 0.65);
|
||
|
||
// 后30%:70% 到 100%
|
||
int back30StartFrame = (int)(totalFrames * 0.70);
|
||
int back30EndFrame = totalFrames - 1; // 最后一帧
|
||
|
||
// 确保范围有效
|
||
if (front30EndFrame <= front30StartFrame) front30EndFrame = front30StartFrame + 1;
|
||
if (middle30EndFrame <= middle30StartFrame) middle30EndFrame = middle30StartFrame + 1;
|
||
if (back30EndFrame <= back30StartFrame) back30EndFrame = back30StartFrame + 1;
|
||
|
||
// 使用时间戳和文件路径作为随机种子,确保每次处理都不同
|
||
var random = new Random((int)(DateTime.Now.Ticks % int.MaxValue) + inputPath.GetHashCode());
|
||
|
||
// 3. 在每个区域随机选择一帧删除
|
||
int frame1 = random.Next(front30StartFrame, front30EndFrame + 1); // 前30%
|
||
int frame2 = random.Next(middle30StartFrame, middle30EndFrame + 1); // 中间30%
|
||
int frame3 = random.Next(back30StartFrame, back30EndFrame + 1); // 后30%
|
||
|
||
// 确保三帧不重复(如果重复,调整其中一个)
|
||
if (frame2 == frame1) frame2 = (frame2 + 1) % totalFrames;
|
||
if (frame3 == frame1 || frame3 == frame2)
|
||
{
|
||
frame3 = (frame3 + 1) % totalFrames;
|
||
if (frame3 == frame1 || frame3 == frame2) frame3 = (frame3 + 1) % totalFrames;
|
||
}
|
||
|
||
double time1 = frame1 / frameRate;
|
||
double time2 = frame2 / frameRate;
|
||
double time3 = frame3 / frameRate;
|
||
|
||
LogUtils.Info($"开始精确抽帧:输入={inputPath}, 输出={outputPath}, 总帧数={totalFrames}");
|
||
LogUtils.Info($"删除帧1(前30%):帧编号={frame1}, 时间≈{time1:F2}秒");
|
||
LogUtils.Info($"删除帧2(中间30%):帧编号={frame2}, 时间≈{time2:F2}秒");
|
||
LogUtils.Info($"删除帧3(后30%):帧编号={frame3}, 时间≈{time3:F2}秒");
|
||
|
||
// 4. 只删除视频帧,音频直接复制,使用最快编码设置以达到1秒内完成
|
||
// 注意:使用 select 过滤器删除帧时,视频需要重新编码,但使用最快设置
|
||
LogUtils.Info("使用最快模式删除帧(仅处理视频,音频直接复制)...");
|
||
|
||
var ffmpegArgs = FFMpegArguments
|
||
.FromFileInput(workingInputPath)
|
||
.OutputToFile(outputPath, true, options =>
|
||
{
|
||
// 使用 select 过滤器删除三帧(只删除视频帧)
|
||
options.WithCustomArgument($"-vf select='not(eq(n\\,{frame1})+eq(n\\,{frame2})+eq(n\\,{frame3}))'");
|
||
|
||
// 视频编码:使用最快设置
|
||
options.WithVideoCodec("libx264");
|
||
options.WithCustomArgument("-preset ultrafast"); // 最快编码预设
|
||
options.WithCustomArgument("-tune zerolatency"); // 零延迟调优
|
||
options.WithConstantRateFactor(28); // 质量设置(28比23快)
|
||
options.WithCustomArgument("-g 30"); // 减少关键帧间隔
|
||
options.WithCustomArgument("-threads 0"); // 使用所有CPU核心
|
||
options.WithCustomArgument("-vsync cfr"); // 恒定帧率
|
||
|
||
// 音频:直接复制,不重新编码(最快,不处理)
|
||
options.WithAudioCodec("copy"); // 直接复制音频流,零处理时间
|
||
});
|
||
|
||
bool success = ffmpegArgs.ProcessSynchronously();
|
||
|
||
// 验证输出文件是否存在且有效
|
||
if (success && File.Exists(outputPath))
|
||
{
|
||
FileInfo fileInfo = new FileInfo(outputPath);
|
||
if (fileInfo.Length > 0)
|
||
{
|
||
// 验证输出视频的帧数是否正确(应该比原视频少3帧)
|
||
try
|
||
{
|
||
var outputMediaInfo = await FFProbe.AnalyseAsync(outputPath);
|
||
var outputVideoStream = outputMediaInfo.PrimaryVideoStream;
|
||
if (outputVideoStream != null)
|
||
{
|
||
int expectedFrames = totalFrames - 3;
|
||
double outputDuration = outputMediaInfo.Duration.TotalSeconds;
|
||
int actualFrames = (int)(outputDuration * outputVideoStream.FrameRate);
|
||
|
||
LogUtils.Info($"抽帧完成:输出文件={outputPath}, 大小={fileInfo.Length / 1024 / 1024:F2}MB, 时长={outputDuration:F2}秒, 帧数≈{actualFrames} (预期少3帧)");
|
||
|
||
// 验证时长是否合理(应该略小于原视频)
|
||
if (outputDuration >= totalDuration)
|
||
{
|
||
LogUtils.Warn($"警告:输出视频时长({outputDuration:F2}秒) >= 原视频时长({totalDuration:F2}秒),可能未成功删除帧");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Warn($"验证输出视频信息时出错(不影响结果):{ex.Message}");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"输出文件存在但大小为0:{outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"抽帧失败:success={success}, 文件存在={File.Exists(outputPath)}, 输出路径={outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Error("抽帧过程发生异常", ex);
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
// 清理临时目录
|
||
try
|
||
{
|
||
if (Directory.Exists(tempDir))
|
||
{
|
||
string[] files = Directory.GetFiles(tempDir);
|
||
foreach (string file in files)
|
||
{
|
||
try
|
||
{
|
||
File.Delete(file);
|
||
}
|
||
catch
|
||
{
|
||
// 忽略删除失败
|
||
}
|
||
}
|
||
try
|
||
{
|
||
Directory.Delete(tempDir);
|
||
}
|
||
catch
|
||
{
|
||
// 忽略删除失败
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 忽略清理错误
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// 通过修改视频元数据(添加注释)改变MD5
|
||
/// </summary>
|
||
public static bool ModifyByMetadata(string inputPath, string outputPath, string comment = "JSY")
|
||
{
|
||
// 添加或修改视频元数据中的注释信息
|
||
return FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.OutputToFile(outputPath, true, options => options
|
||
//.WithVideoCodec("copy") // 直接复制视频流,不重新编码
|
||
//.WithAudioCodec("copy") // 直接复制音频流
|
||
.CopyChannel()
|
||
.WithCustomArgument($"-metadata comment=\"{comment}_{Guid.NewGuid()}\"") // 添加唯一注释
|
||
)
|
||
.ProcessSynchronously();
|
||
}
|
||
|
||
|
||
public static bool SubVideo(string inputPath, string outputPath, Double startSec, Double endSec)
|
||
{
|
||
try
|
||
{
|
||
// 计算时长
|
||
double duration = endSec - startSec;
|
||
if (duration <= 0)
|
||
{
|
||
LogUtils.Error($"SubVideo失败:时长无效,开始={startSec}秒, 结束={endSec}秒");
|
||
return false;
|
||
}
|
||
|
||
LogUtils.Info($"SubVideo:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒");
|
||
|
||
// 使用输入时的 -ss 参数(在输入参数中),这样可以更精确地定位,减少重新编码
|
||
// 使用 -c copy 确保不重新编码
|
||
bool result = FFMpegArguments
|
||
.FromFileInput(inputPath, true, options => options
|
||
.WithCustomArgument($"-ss {startSec:F3}")) // 在输入时指定起始时间,更精确
|
||
.OutputToFile(outputPath, true, options => options
|
||
.WithCustomArgument($"-t {duration:F3}") // 指定时长
|
||
.WithVideoCodec("copy") // 明确指定复制视频流
|
||
.WithAudioCodec("copy")) // 明确指定复制音频流
|
||
.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;
|
||
}
|
||
}
|
||
|
||
|
||
public static bool SubAudio(string inputPath, string outputPath, Double startSec, Double endSec)
|
||
{
|
||
return FFMpegArguments
|
||
.FromFileInput(inputPath, true, options => options.Seek(TimeSpan.FromSeconds(startSec)).EndSeek(TimeSpan.FromSeconds(endSec)))
|
||
.OutputToFile(outputPath, true, options => options.CopyChannel())
|
||
.ProcessSynchronously();
|
||
}
|
||
|
||
public static bool JoinVideo(string outPutPath, string[] 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)
|
||
{
|
||
// 1. 获取视频信息
|
||
IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||
var videoStream = mediaInfo.PrimaryVideoStream;
|
||
if (videoStream == null)
|
||
{
|
||
Console.WriteLine("没有找到视频流");
|
||
return false;
|
||
}
|
||
// 视频总时长(秒)
|
||
var totalDuration = mediaInfo.Duration.TotalSeconds;
|
||
|
||
// 计算帧时间参数
|
||
double frameRate = videoStream.FrameRate;
|
||
double frameDuration = Math.Round(1.0 / frameRate, 6); // 一帧时长(秒)
|
||
|
||
int totalFram = (int)(totalDuration * frameRate);
|
||
// 2. 随机生成要删除的帧的时间点(避开最后一帧,防止越界)
|
||
var random = new Random();
|
||
var randomFrame = random.Next(20, totalFram - 10);
|
||
|
||
double frameTime = Math.Round((randomFrame - 1) * frameDuration, 6); // 目标帧开始时间
|
||
double nextFrameTime = Math.Round((randomFrame + 1) * frameDuration, 6); // 下一帧开始时间
|
||
// 临时文件路径
|
||
string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
string audioPath = Path.Combine(tempDir, $"{Guid.NewGuid()}.aac");
|
||
string videoNoaudioPath = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
string finalyPath = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
|
||
string audioPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.aac");
|
||
string audioPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.aac");
|
||
|
||
|
||
// 分离视频流(不含音频)
|
||
await FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.OutputToFile(videoNoaudioPath, true, opt => opt.WithCustomArgument("-an -c:v copy"))
|
||
.ProcessAsynchronously();
|
||
|
||
if (!File.Exists(videoNoaudioPath))
|
||
return false;
|
||
|
||
// 分离音频流(不含视频)(如有音频)
|
||
if (mediaInfo.PrimaryAudioStream != null)
|
||
{
|
||
await FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.OutputToFile(audioPath, true, opt => opt.WithCustomArgument("-vn -c:a copy"))
|
||
.ProcessAsynchronously();
|
||
|
||
if (!File.Exists(audioPath))
|
||
return false;
|
||
}
|
||
|
||
|
||
inputPath = videoNoaudioPath;
|
||
|
||
|
||
|
||
|
||
bool hasSubVideo1 = SubVideo(inputPath, videoPart1, 0, frameTime);
|
||
bool hasSubVideo2 = SubVideo(inputPath, videoPart2, nextFrameTime, totalDuration);
|
||
if (!hasSubVideo1 || !hasSubVideo2)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
|
||
bool isJoinSuccess = JoinVideo(finalyPath, [videoPart1, videoPart2]);
|
||
if (!isJoinSuccess)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return await FFMpegArguments.FromFileInput(finalyPath)
|
||
.AddFileInput(audioPath)
|
||
.OutputToFile(outputPath, true, opt =>
|
||
opt.WithCustomArgument("-c copy -shortest -y"))
|
||
.ProcessAsynchronously();
|
||
}
|
||
|
||
|
||
public static bool RemoveVideoFrame(string inputPath, string outputPath, int frameToRemove)
|
||
{
|
||
// 使用FFMpegArguments构建转换流程
|
||
return FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.OutputToFile(outputPath, true, options => options
|
||
// 使用select过滤器排除指定帧(帧序号从0开始)
|
||
.WithCustomArgument($"select=not(eq(n\\,{frameToRemove}))")
|
||
|
||
// $"not(eq(n\\,{frameToRemove}))"); // 注意:在C#中需要转义反斜杠
|
||
//// 保持其他编码参数
|
||
.WithVideoCodec("libx264")
|
||
.WithAudioCodec("copy") // 如果不需要处理音频,直接复制
|
||
)
|
||
.ProcessSynchronously();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 随机删除一个非关键帧
|
||
/// </summary>
|
||
/// <param name="inputPath">输入文件路径</param>
|
||
/// <param name="outputPath">输出文件路径</param>
|
||
/// <param name="index">当前处理的序号(用于确保每次删除位置不同)</param>
|
||
/// <returns>操作是否成功</returns>
|
||
public static async Task<bool> RemoveNonKeyFrameAsync(string inputPath, string outputPath, int index = 0)
|
||
{
|
||
if (!File.Exists(inputPath))
|
||
{
|
||
LogUtils.Error($"输入文件不存在:{inputPath}");
|
||
return false;
|
||
}
|
||
|
||
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||
try
|
||
{
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
// 1. 获取视频信息
|
||
IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||
var videoStream = mediaInfo.PrimaryVideoStream;
|
||
if (videoStream == null)
|
||
{
|
||
LogUtils.Error("没有找到视频流");
|
||
return false;
|
||
}
|
||
|
||
double totalDuration = mediaInfo.Duration.TotalSeconds;
|
||
double frameRate = videoStream.FrameRate;
|
||
|
||
// 确保视频时长足够(至少20秒)
|
||
if (totalDuration < 20)
|
||
{
|
||
LogUtils.Error($"视频时长太短({totalDuration:F2}秒),无法抽帧(需要至少20秒)");
|
||
return false;
|
||
}
|
||
|
||
// 计算总帧数
|
||
int totalFrames = (int)(totalDuration * frameRate);
|
||
if (totalFrames <= 10)
|
||
{
|
||
LogUtils.Error($"视频帧数太少({totalFrames}帧),无法删除帧");
|
||
return false;
|
||
}
|
||
|
||
// 2. 找出非关键帧(避开前5帧和后5帧,以及关键帧)
|
||
// 使用index确保每次删除的位置不同(基于输入路径和序号生成不同位置)
|
||
// 关键帧通常是每30帧一个(取决于编码设置)
|
||
|
||
// 避开前5%和后5%的帧,在这些范围内选择
|
||
int startFrame = Math.Max(5, (int)(totalFrames * 0.05));
|
||
int endFrame = Math.Min(totalFrames - 5, (int)(totalFrames * 0.95));
|
||
int availableRange = endFrame - startFrame;
|
||
|
||
// 使用输入路径的哈希值和index来确定删除位置,确保每次不同
|
||
int hashSeed = inputPath.GetHashCode() + index * 997; // 997是质数,帮助分散
|
||
var random = new Random(hashSeed);
|
||
|
||
// 在可用范围内基于index均匀分布选择位置
|
||
// 使用模运算确保index越大,位置也相应变化
|
||
double ratio = (index % (availableRange / 3)) / (double)(availableRange / 3); // 使用1/3范围来分散
|
||
int baseFrame = startFrame + (int)(ratio * availableRange);
|
||
|
||
// 在baseFrame附近随机选择,但确保每次都不同
|
||
int offsetRange = Math.Max(1, availableRange / 20); // 在baseFrame附近±5%范围内
|
||
int offset = random.Next(-offsetRange, offsetRange);
|
||
int selectedFrame = baseFrame + offset;
|
||
|
||
// 确保在有效范围内
|
||
selectedFrame = Math.Max(startFrame, Math.Min(endFrame, selectedFrame));
|
||
|
||
// 确保不是关键帧位置(关键帧通常在0, 30, 60, 90...位置)
|
||
while (selectedFrame % 30 == 0 && selectedFrame < endFrame - 1)
|
||
{
|
||
selectedFrame++;
|
||
}
|
||
|
||
// 如果还是关键帧位置,尝试减1
|
||
if (selectedFrame % 30 == 0 && selectedFrame > startFrame)
|
||
{
|
||
selectedFrame--;
|
||
}
|
||
|
||
double frameDuration = 1.0 / frameRate; // 一帧的时长
|
||
|
||
// 找到要删除帧所在GOP的起始关键帧位置
|
||
// 关键帧通常每30帧一个(取决于编码设置)
|
||
int gopSize = 30; // 默认GOP大小
|
||
int gopStartFrame = (selectedFrame / gopSize) * gopSize; // 找到当前帧所在GOP的起始关键帧
|
||
|
||
// 计算裁剪时间点
|
||
// 策略:在关键帧边界处裁剪,确保可以完全使用copy模式
|
||
// 第一部分:从开始到删除帧所在GOP的起始关键帧(包含关键帧)
|
||
// 第二部分:从删除帧的下一帧开始到结束
|
||
double frameTime = gopStartFrame * frameDuration; // 在第一部分保留关键帧
|
||
double nextFrameTime = (selectedFrame + 1) * frameDuration; // 第二部分从删除帧的下一帧开始
|
||
|
||
// 如果删除的帧就在或非常接近关键帧位置,调整策略
|
||
if (selectedFrame <= gopStartFrame + 1)
|
||
{
|
||
// 如果删除的帧太靠近关键帧,从下一个GOP开始
|
||
int nextGopStart = gopStartFrame + gopSize;
|
||
if (nextGopStart < totalFrames)
|
||
{
|
||
frameTime = gopStartFrame * frameDuration;
|
||
nextFrameTime = nextGopStart * frameDuration;
|
||
}
|
||
else
|
||
{
|
||
// 如果已经是最后一个GOP,调整到前一个GOP
|
||
int prevGopStart = Math.Max(0, gopStartFrame - gopSize);
|
||
frameTime = prevGopStart * frameDuration;
|
||
nextFrameTime = gopStartFrame * frameDuration;
|
||
}
|
||
}
|
||
|
||
// 确保时间有效
|
||
frameTime = Math.Max(0, frameTime);
|
||
nextFrameTime = Math.Min(totalDuration, nextFrameTime);
|
||
|
||
// 确保第二部分时间大于第一部分
|
||
if (nextFrameTime <= frameTime)
|
||
{
|
||
nextFrameTime = frameTime + frameDuration;
|
||
}
|
||
|
||
LogUtils.Info($"开始删除非关键帧(在关键帧边界裁剪以保持copy模式):输入={inputPath}, 输出={outputPath}, 总帧数={totalFrames}, 删除帧编号={selectedFrame}, GOP起始帧={gopStartFrame}, 分割时间={frameTime:F3}秒和{nextFrameTime:F3}秒");
|
||
|
||
// 3. 使用时间分割方式直接删除帧,不重新编码
|
||
// 将视频分为两部分:删除帧之前和之后,然后合并
|
||
string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
string mergedVideo = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4");
|
||
|
||
// 分离音频(如有)
|
||
string audioPath = Path.Combine(tempDir, $"{Guid.NewGuid()}.aac");
|
||
bool hasAudio = mediaInfo.PrimaryAudioStream != null;
|
||
|
||
if (hasAudio)
|
||
{
|
||
bool audioExtracted = FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.OutputToFile(audioPath, true, options => options
|
||
.WithCustomArgument("-vn -c:a copy"))
|
||
.ProcessSynchronously();
|
||
|
||
if (!audioExtracted || !File.Exists(audioPath))
|
||
{
|
||
LogUtils.Warn("音频分离失败,继续仅处理视频");
|
||
hasAudio = false;
|
||
}
|
||
}
|
||
|
||
// 裁剪第一部分(从开始到删除帧之前)
|
||
bool part1Success = false;
|
||
if (frameTime > 0)
|
||
{
|
||
part1Success = SubVideo(inputPath, videoPart1, 0, frameTime);
|
||
if (!part1Success || !File.Exists(videoPart1))
|
||
{
|
||
LogUtils.Error($"裁剪第一部分失败");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 裁剪第二部分(从删除帧之后到结束)
|
||
bool part2Success = SubVideo(inputPath, videoPart2, nextFrameTime, totalDuration);
|
||
if (!part2Success || !File.Exists(videoPart2))
|
||
{
|
||
LogUtils.Error($"裁剪第二部分失败");
|
||
return false;
|
||
}
|
||
|
||
// 合并两部分视频
|
||
List<string> videoParts = [];
|
||
if (frameTime > 0 && File.Exists(videoPart1))
|
||
{
|
||
videoParts.Add(videoPart1);
|
||
}
|
||
if (File.Exists(videoPart2))
|
||
{
|
||
videoParts.Add(videoPart2);
|
||
}
|
||
|
||
if (videoParts.Count == 0)
|
||
{
|
||
LogUtils.Error("没有可合并的视频部分");
|
||
return false;
|
||
}
|
||
|
||
bool mergeSuccess = false;
|
||
if (videoParts.Count == 1)
|
||
{
|
||
// 如果只有一部分,直接复制
|
||
File.Copy(videoParts[0], mergedVideo, true);
|
||
mergeSuccess = File.Exists(mergedVideo);
|
||
}
|
||
else
|
||
{
|
||
mergeSuccess = JoinVideo(mergedVideo, videoParts.ToArray());
|
||
}
|
||
|
||
if (!mergeSuccess || !File.Exists(mergedVideo))
|
||
{
|
||
LogUtils.Error("合并视频失败");
|
||
return false;
|
||
}
|
||
|
||
// 如果有音频,合并音频和视频
|
||
bool success = false;
|
||
if (hasAudio && File.Exists(audioPath))
|
||
{
|
||
// 使用明确的 copy 参数,确保不重新编码
|
||
success = await FFMpegArguments
|
||
.FromFileInput(mergedVideo)
|
||
.AddFileInput(audioPath)
|
||
.OutputToFile(outputPath, true, options => options
|
||
.WithVideoCodec("copy") // 明确指定复制视频流
|
||
.WithAudioCodec("copy") // 明确指定复制音频流
|
||
.WithCustomArgument("-shortest -y"))
|
||
.ProcessAsynchronously();
|
||
}
|
||
else
|
||
{
|
||
// 没有音频,直接复制视频
|
||
File.Copy(mergedVideo, outputPath, true);
|
||
success = File.Exists(outputPath);
|
||
}
|
||
|
||
// 验证输出文件
|
||
if (success && File.Exists(outputPath))
|
||
{
|
||
FileInfo outputFileInfo = new FileInfo(outputPath);
|
||
FileInfo inputFileInfo = new FileInfo(inputPath);
|
||
|
||
if (outputFileInfo.Length > 0)
|
||
{
|
||
try
|
||
{
|
||
var outputMediaInfo = await FFProbe.AnalyseAsync(outputPath);
|
||
double outputDuration = outputMediaInfo.Duration.TotalSeconds;
|
||
|
||
long sizeDiff = outputFileInfo.Length - inputFileInfo.Length;
|
||
double sizeDiffMB = sizeDiff / 1024.0 / 1024.0;
|
||
string sizeChange = sizeDiff > 0 ? $"+{sizeDiffMB:F2}MB" : sizeDiff < 0 ? $"{sizeDiffMB:F2}MB" : "0MB";
|
||
|
||
LogUtils.Info($"删除非关键帧完成:输出文件={outputPath}, 原始大小={inputFileInfo.Length / 1024.0 / 1024.0:F2}MB, 输出大小={outputFileInfo.Length / 1024.0 / 1024.0:F2}MB ({sizeChange}), 时长={outputDuration:F2}秒");
|
||
|
||
if (sizeDiff > 0)
|
||
{
|
||
LogUtils.Warn($"注意:输出文件比原始文件大了 {sizeDiffMB:F2}MB,这可能是因为在非关键帧位置裁剪需要重新编码部分内容");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Warn($"验证输出视频信息时出错(不影响结果):{ex.Message}");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"输出文件存在但大小为0:{outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"删除非关键帧失败:success={success}, 文件存在={File.Exists(outputPath)}, 输出路径={outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Error("删除非关键帧过程发生异常", ex);
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
// 清理临时目录
|
||
try
|
||
{
|
||
if (Directory.Exists(tempDir))
|
||
{
|
||
string[] files = Directory.GetFiles(tempDir);
|
||
foreach (string file in files)
|
||
{
|
||
try
|
||
{
|
||
File.Delete(file);
|
||
}
|
||
catch { }
|
||
}
|
||
try
|
||
{
|
||
Directory.Delete(tempDir);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在随机位置添加一个肉眼无法感知的小像素透明图
|
||
/// </summary>
|
||
/// <param name="inputPath">输入文件路径</param>
|
||
/// <param name="outputPath">输出文件路径</param>
|
||
/// <returns>操作是否成功</returns>
|
||
public static async Task<bool> AddTransparentImageAsync(string inputPath, string outputPath)
|
||
{
|
||
if (!File.Exists(inputPath))
|
||
{
|
||
LogUtils.Error($"输入文件不存在:{inputPath}");
|
||
return false;
|
||
}
|
||
|
||
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||
try
|
||
{
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
// 1. 获取视频信息
|
||
IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||
var videoStream = mediaInfo.PrimaryVideoStream;
|
||
if (videoStream == null)
|
||
{
|
||
LogUtils.Error("没有找到视频流");
|
||
return false;
|
||
}
|
||
|
||
int videoWidth = videoStream.Width;
|
||
int videoHeight = videoStream.Height;
|
||
double totalDuration = mediaInfo.Duration.TotalSeconds;
|
||
|
||
// 确保视频时长足够
|
||
if (totalDuration < 1)
|
||
{
|
||
LogUtils.Error($"视频时长太短({totalDuration:F2}秒)");
|
||
return false;
|
||
}
|
||
|
||
// 2. 随机选择叠加位置(可以在视频的任意位置,1x1透明图肉眼无法感知)
|
||
var random = new Random((int)(DateTime.Now.Ticks % int.MaxValue) + inputPath.GetHashCode());
|
||
// 完全随机选择位置,覆盖整个视频区域(0到宽度-1,0到高度-1)
|
||
int x = random.Next(0, videoWidth);
|
||
int y = random.Next(0, videoHeight);
|
||
|
||
// 3. 创建1x1透明PNG文件(使用FFmpeg命令行直接执行)
|
||
string transparentImagePath = Path.Combine(tempDir, "transparent_1x1.png");
|
||
|
||
// 使用FFmpeg命令行创建透明PNG(通过System.Diagnostics.Process)
|
||
try
|
||
{
|
||
// 查找ffmpeg.exe路径
|
||
string ffmpegPath = "";
|
||
|
||
// 方法1: 尝试从当前程序目录查找
|
||
string currentDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "";
|
||
ffmpegPath = Path.Combine(currentDir, "ffmpeg.exe");
|
||
|
||
// 方法2: 如果在net8.0-windows目录下
|
||
if (!File.Exists(ffmpegPath))
|
||
{
|
||
string netDir = Path.Combine(currentDir, "net8.0-windows");
|
||
ffmpegPath = Path.Combine(netDir, "ffmpeg.exe");
|
||
}
|
||
|
||
// 方法3: 从系统PATH查找
|
||
if (!File.Exists(ffmpegPath))
|
||
{
|
||
ffmpegPath = "ffmpeg.exe"; // 如果在PATH中
|
||
}
|
||
|
||
if (!File.Exists(ffmpegPath) && ffmpegPath != "ffmpeg.exe")
|
||
{
|
||
LogUtils.Error($"无法找到ffmpeg.exe,已尝试路径:{Path.Combine(currentDir, "ffmpeg.exe")}");
|
||
return false;
|
||
}
|
||
|
||
var processInfo = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = ffmpegPath,
|
||
Arguments = $"-f lavfi -i \"color=c=0x00000000:s=1x1:d=0.04\" -frames:v 1 -pix_fmt rgba \"{transparentImagePath}\"",
|
||
UseShellExecute = false,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
CreateNoWindow = true
|
||
};
|
||
|
||
using (var process = System.Diagnostics.Process.Start(processInfo))
|
||
{
|
||
if (process == null)
|
||
{
|
||
LogUtils.Error("无法启动ffmpeg进程");
|
||
return false;
|
||
}
|
||
|
||
process.WaitForExit();
|
||
|
||
if (process.ExitCode != 0)
|
||
{
|
||
string error = await process.StandardError.ReadToEndAsync();
|
||
LogUtils.Error($"FFmpeg创建透明PNG失败,退出码:{process.ExitCode},错误:{error}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!File.Exists(transparentImagePath))
|
||
{
|
||
LogUtils.Error("透明PNG文件创建失败,文件不存在");
|
||
return false;
|
||
}
|
||
|
||
LogUtils.Info($"透明PNG文件已创建:{transparentImagePath}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Error($"创建透明PNG文件失败:{ex.Message}", ex);
|
||
return false;
|
||
}
|
||
|
||
LogUtils.Info($"开始添加透明图:输入={inputPath}, 输出={outputPath}, 视频尺寸={videoWidth}x{videoHeight}, 叠加位置=({x},{y})");
|
||
|
||
// 4. 使用overlay过滤器在指定位置叠加透明图(在整个视频中持续存在)
|
||
var ffmpegArgs = FFMpegArguments
|
||
.FromFileInput(inputPath)
|
||
.AddFileInput(transparentImagePath)
|
||
.OutputToFile(outputPath, true, options =>
|
||
{
|
||
// 使用overlay过滤器叠加透明图,在整个视频的所有帧中都存在
|
||
// 透明图会自动循环以适应视频长度
|
||
options.WithCustomArgument($"-filter_complex \"[0:v][1:v]overlay={x}:{y}:shortest=0[v]\"");
|
||
options.WithCustomArgument("-map \"[v]\"");
|
||
|
||
// 视频编码:使用最快设置
|
||
options.WithVideoCodec("libx264");
|
||
options.WithCustomArgument("-preset ultrafast");
|
||
options.WithCustomArgument("-tune zerolatency");
|
||
options.WithConstantRateFactor(28);
|
||
options.WithCustomArgument("-g 30");
|
||
options.WithCustomArgument("-threads 0");
|
||
options.WithCustomArgument("-vsync cfr");
|
||
|
||
// 音频:直接复制
|
||
options.WithCustomArgument("-map 0:a?");
|
||
options.WithAudioCodec("copy");
|
||
});
|
||
|
||
bool success = ffmpegArgs.ProcessSynchronously();
|
||
|
||
// 验证输出文件
|
||
if (success && File.Exists(outputPath))
|
||
{
|
||
FileInfo fileInfo = new FileInfo(outputPath);
|
||
if (fileInfo.Length > 0)
|
||
{
|
||
try
|
||
{
|
||
var outputMediaInfo = await FFProbe.AnalyseAsync(outputPath);
|
||
double outputDuration = outputMediaInfo.Duration.TotalSeconds;
|
||
LogUtils.Info($"添加透明图完成:输出文件={outputPath}, 大小={fileInfo.Length / 1024 / 1024:F2}MB, 时长={outputDuration:F2}秒");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Warn($"验证输出视频信息时出错(不影响结果):{ex.Message}");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"输出文件存在但大小为0:{outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
LogUtils.Error($"添加透明图失败:success={success}, 文件存在={File.Exists(outputPath)}, 输出路径={outputPath}");
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogUtils.Error("添加透明图过程发生异常", ex);
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
// 清理临时目录
|
||
try
|
||
{
|
||
if (Directory.Exists(tempDir))
|
||
{
|
||
string[] files = Directory.GetFiles(tempDir);
|
||
foreach (string file in files)
|
||
{
|
||
try
|
||
{
|
||
File.Delete(file);
|
||
}
|
||
catch { }
|
||
}
|
||
try
|
||
{
|
||
Directory.Delete(tempDir);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
}
|
||
}
|