VideoConcat/Services/Video/VideoProcess.cs
2026-01-10 19:00:52 +08:00

986 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.264select 过滤器在某些编码格式下可能不稳定)
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到宽度-10到高度-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 { }
}
}
}
}