Loading src/VideoSlice.Cli/MainCommand.cs +215 −23 Original line number Diff line number Diff line using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; using shRabbit.Base.IO.Directories; using shRabbit.Base.IO.Files; using Spectre.Console; using Spectre.Console.Cli; Loading Loading @@ -44,7 +46,7 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> public string SliceTime { get; set; } = "30m"; [CommandOption("-a|--hwaccel")] [Description("FFmpeg硬件解码加速名称(如cuda/dxva2/qsv),留空则不启用。")] [Description("FFmpeg硬件解码加速名称(如cuda/dxva2/qsv);cuda会自动选用对应cuvid解码器,留空则不启用。")] public string HwAccel { get; set; } = string.Empty; [CommandOption("-n|--no-exec")] Loading @@ -52,42 +54,159 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> public bool NoExecute { get; set; } = false; } private static readonly Dictionary<string, string> CuvidDecoders = new(StringComparer.OrdinalIgnoreCase) { ["h264"] = "h264_cuvid", ["hevc"] = "hevc_cuvid", ["av1"] = "av1_cuvid", ["vp9"] = "vp9_cuvid", ["mpeg2video"] = "mpeg2_cuvid", }; /// <summary> /// 生成 ffmpeg 调用参数。 /// 生成 ffmpeg 调用参数。每个时间点生成一条命令,通过 -ss 输入快速定位, /// 只读取目标点附近的关键帧段,避免解码整段视频导致长时间等待。 /// </summary> /// <param name="settings"></param> /// <param name="file"></param> /// <param name="dir"></param> /// <param name="file">视频文件路径。</param> /// <param name="duration">视频时长。</param> /// <param name="codec">视频编码器名称(如 h264/hevc/av1)。</param> /// <returns>两层数组,第一次是每个命令,第二次是命令中的参数。</returns> public static IEnumerable<string[]> GenerateFfMpegCommand(Settings settings, IFile file, IDirectory dir) public static IEnumerable<string[]> GenerateFfMpegCommand(Settings settings, string file, TimeSpan duration, string codec) { var time = SimpleTimeSpanExpress.ToTimeSpan(settings.SliceTime); var interval = SimpleTimeSpanExpress.ToTimeSpan(settings.SliceTime); if (interval <= TimeSpan.Zero) throw new ArgumentException($"无效的分片间隔:'{settings.SliceTime}'。", nameof(settings)); foreach (var f in GetFiles(settings, dir)) { var output = Path.Join(Path.Combine(settings.Directory, settings.Disc), $"{Path.GetFileName(f)}_%03d.{settings.Format}"); var pointCount = duration > TimeSpan.Zero ? Math.Max(1, (int)Math.Ceiling(duration.TotalSeconds / interval.TotalSeconds)) : 1; var width = Math.Max(3, pointCount.ToString().Length); var outputDir = Path.Combine(settings.Directory, settings.Disc); var baseName = Path.GetFileName(file); for (var i = 0; i < pointCount; i++) { var args = new List<string>(7); if (!string.IsNullOrEmpty(settings.HwAccel)) { if (settings.HwAccel == "cuda" && CuvidDecoders.TryGetValue(codec, out var cuvid)) { args.Add("-c:v"); args.Add(cuvid); } else { args.Add("-hwaccel"); args.Add(settings.HwAccel); } } // 只解码关键帧,避免大间隔时解码全部帧导致长时间等待 args.Add("-skip_frame"); args.Add("nokey"); args.Add("-ss"); args.Add((i * interval.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture)); args.Add("-i"); args.Add($"\"{f}\""); args.Add("-vf"); args.Add($"\"fps=1/{time.TotalSeconds}\""); args.Add($"\"{output}\""); args.Add($"\"{file}\""); args.Add("-frames:v"); args.Add("1"); args.Add($"\"{Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")}\""); yield return args.ToArray(); } } /// <summary> /// 通过 ffmpeg 探测视频时长与编码器名称。 /// </summary> /// <param name="ffmpegPath">ffmpeg 可执行文件路径。</param> /// <param name="file">视频文件路径。</param> /// <param name="verbose">输出日志记录</param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ProbeMedia(string ffmpegPath, string file, bool verbose) { var si = new ProcessStartInfo { FileName = ffmpegPath, RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using var process = new Process { StartInfo = si }; process.StartInfo.ArgumentList.Add("-hide_banner"); process.StartInfo.ArgumentList.Add("-i"); process.StartInfo.ArgumentList.Add(file); ShowStartInfo(si, "启动探测:"); if (!process.Start()) throw new InvalidOperationException($"无法启动 ffmpeg:'{ffmpegPath}'。"); var stderr = process.StandardError.ReadToEnd(); if (!process.WaitForExit(TimeSpan.FromSeconds(15))) { process.Kill(); throw new InvalidOperationException($"探测视频信息超时:'{file}'。"); } return ParseProbeOutput(stderr, verbose); } /// <summary> /// 解析 ffmpeg -i 输出中的时长与视频编码器名称。 /// </summary> /// <param name="output">ffmpeg -i 的标准错误输出。</param> /// <param name="verbose"></param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose) { if (verbose) AnsiConsole.MarkupLineInterpolated($"[grey]{output}[/]"); var duration = TimeSpan.Zero; var codec = string.Empty; foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { if (duration == TimeSpan.Zero) { var dIndex = line.IndexOf("Duration:", StringComparison.OrdinalIgnoreCase); if (dIndex >= 0) { var rest = line[(dIndex + "Duration:".Length)..].TrimStart(); var end = rest.IndexOf(','); var text = end >= 0 ? rest[..end] : rest; if (TimeSpan.TryParse(text, CultureInfo.InvariantCulture, out var d)) duration = d; } } if (codec.Length == 0) { var vIndex = line.IndexOf("Video:", StringComparison.OrdinalIgnoreCase); if (vIndex >= 0) { var rest = line[(vIndex + "Video:".Length)..].TrimStart(); var space = rest.IndexOf(' '); codec = space >= 0 ? rest[..space] : rest; } } } if (verbose) AnsiConsole.MarkupLineInterpolated($"→ [Aquamarine1_1]{duration}, {codec}[/]"); return (duration, codec); } public static IEnumerable<string> GetFiles(Settings settings, IDirectory dir) { if (settings.File is { } s) yield break; Loading @@ -110,13 +229,86 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> { if (settings.NoExecute) { foreach (var arr in GenerateFfMpegCommand(settings, FileBridge.Default, DirectoryBridge.Default)) DirectoryBridge.Default.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) { var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) { AnsiConsole.WriteLine($"{settings.FfMpegPath} {string.Join('\u0020', arr)}"); } } return 0; } var tasks = new List<Task>(); var semaphore = new SemaphoreSlim((int)settings.ParallelNumber); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) { AnsiConsole.WriteLine($"找到文件:{f}"); var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); var count = 0; foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) { var si = new ProcessStartInfo(settings.FfMpegPath, arr); ShowStartInfo(si, $"创建的启动 [[{count}]]:"); tasks.Add(ExecuteSemaphore(semaphore, si, cancellationToken)); count++; } } await Task.WhenAll(tasks); return 0; } public static void ShowStartInfo(ProcessStartInfo si, string header) { var tree = new Tree(header); tree.AddNode($"启动程序:[cyan]{Markup.Escape(si.FileName)}[/]"); tree.AddNode($"参数:[green]{Markup.Escape(ShowArray(si.ArgumentList))}[/]"); tree.AddNode($"工作路径:[gray]{Markup.Escape(si.WorkingDirectory)}[/]"); AnsiConsole.Write(tree); AnsiConsole.WriteLine(); } public static string ShowArray<T>(IEnumerable<T> array) { var sb = new StringBuilder(); sb.Append("[ "); sb.Append(string.Join(", ", array)); sb.Append(" ]"); return sb.ToString(); } public async Task ExecuteSemaphore(SemaphoreSlim semaphore, ProcessStartInfo startInfo, CancellationToken cancellationToken) { await semaphore.WaitAsync(cancellationToken); try { var process = Process.Start(startInfo)!; await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) { var tree = new Tree($"[red]出现错误 ({startInfo.FileName} {startInfo.Arguments})[/]"); tree.AddNode($"返回值:[red]{process.ExitCode}[/]"); tree.AddNode("错误流") .AddNode(await process.StandardError.ReadToEndAsync(cancellationToken)); tree.AddNode("输出流") .AddNode(await process.StandardOutput.ReadToEndAsync(cancellationToken)); AnsiConsole.Write(tree); } } finally { semaphore.Release(); } } } src/VideoSlice.Cli/Program.cs +3 −6 Original line number Diff line number Diff line using Spectre.Console.Cli; using Spectre.Console; using Spectre.Console.Cli; namespace VideoSlice.Cli; Loading @@ -6,11 +7,7 @@ class Program { static int Main(string[] args) { var app = new CommandApp(); app.Configure(c => { c.AddCommand<MainCommand>("gen"); }); var app = new CommandApp<MainCommand>(); return app.Run(args); } } No newline at end of file src/VideoSlice.Cli/VideoSlice.Cli.csproj +0 −2 Original line number Diff line number Diff line Loading @@ -9,10 +9,8 @@ </PropertyGroup> <ItemGroup> <PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" /> <PackageReference Include="JetBrains.Annotations" Version="2026.2.0" /> <PackageReference Include="shRabbit.Base" Version="1.12.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="Spectre.Console.Cli" Version="0.55.0" /> </ItemGroup> Loading src/VideoSlice.Tests/MainCommandTest.cs +114 −64 Original line number Diff line number Diff line Loading @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using JetBrains.Annotations; using shRabbit.Base.IO.Directories; using shRabbit.Base.IO.Files; using VideoSlice.Cli; using Xunit; using Assert = Xunit.Assert; Loading Loading @@ -69,113 +68,164 @@ public class MainCommandTest } [Fact] public void Should_Generate_Command_Per_File() public void Should_Generate_Point_Commands() { var settings = CreateSettings(); var dir = new TestDirectoryBridge( Path.Combine("videos", "a.mp4"), Path.Combine("videos", "b.mp4")); var file = Path.Combine("videos", "a.mp4"); var duration = TimeSpan.FromHours(2); // 2h / 30m = 4 个时间点:0/1800/3600/5400 var commands = MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir).ToArray(); var commands = MainCommand.GenerateFfMpegCommand(settings, file, duration, "h264").ToArray(); Assert.Collection(commands, c => Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")}\"" "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], c), c => Assert.Equal( [ "-ss", "1800", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_001.png")}\"" ], c), c => Assert.Equal( [ "-ss", "3600", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_002.png")}\"" ], c), c => Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "b.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "b.mp4_%03d.png")}\"" "-ss", "5400", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png")}\"" ], c)); } [Fact] public void Should_Generate_Command_With_Custom_Slice_Disc_And_Format() public void Should_Generate_Single_Point_When_Duration_Shorter_Than_Interval() { var settings = CreateSettings(s => var settings = CreateSettings(); var file = Path.Combine("videos", "a.mp4"); var duration = TimeSpan.FromMinutes(10); // 10m < 30m → 只抽取第 0 秒 var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, duration, "h264")); Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Use_Cuvid_Decoder_For_Av1_When_Cuda() { s.SliceTime = "1m"; s.Disc = "frames"; s.Format = "jpg"; }); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var settings = CreateSettings(s => s.HwAccel = "cuda"); var file = Path.Combine("videos", "a.mkv"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "av1")); Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/60\"", $"\"{Path.Join(Path.Combine("videos", "frames"), "a.mp4_%03d.jpg")}\"" "-c:v", "av1_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png")}\"" ], command); } [Fact] public void Should_Quote_File_And_Output_Paths() public void Should_Use_Cuvid_Decoder_For_H264_When_Cuda() { var settings = CreateSettings(s => s.VideoFormats = "mkv"); var fileName = "2026-07-30 20-49-18.mkv"; var dir = new TestDirectoryBridge(Path.Combine("videos", fileName)); var settings = CreateSettings(s => s.HwAccel = "cuda"); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "h264")); Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", fileName)}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), $"{fileName}_%03d.png")}\"" "-c:v", "h264_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Prepend_Hwaccel_When_Set() public void Should_Use_Generic_Hwaccel_When_Codec_Has_No_Cuvid_Decoder() { var settings = CreateSettings(s => s.HwAccel = "cuda"); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "mpeg4")); Assert.Equal( [ "-hwaccel", "cuda", "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")}\"" "-hwaccel", "cuda", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Generate_Empty_When_No_Files() public void Should_Use_Given_Hwaccel_For_Non_Cuda() { var settings = CreateSettings(s => s.File = Path.Combine("videos", "a.mp4")); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var settings = CreateSettings(s => s.HwAccel = "dxva2"); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "av1")); Assert.Equal( [ "-hwaccel", "dxva2", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Throw_On_Invalid_SliceTime() { var settings = CreateSettings(s => s.SliceTime = "0s"); var file = Path.Combine("videos", "a.mp4"); Assert.Throws<ArgumentException>(() => MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromHours(2), "h264").ToArray()); } [Fact] public void Should_Parse_Duration_And_Codec() { var output = """ Input #0, matroska,webm, from 'K:\Administrator\Videos\x.mkv': Metadata: ENCODER : Lavf61.7.100 Duration: 05:51:30.37, start: 0.000000, bitrate: 4272 kb/s Stream #0:0: Video: av1 (libaom-av1) (Main), yuv420p(pc, bt709), 1920x1080 """; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Equal(new TimeSpan(0, 5, 51, 30, 370), duration); Assert.Equal("av1", codec); } [Fact] public void Should_Parse_Codec_Of_Second_Video_Stream() { var output = """ Duration: 00:10:00.00, start: 0.000000, bitrate: 1000 kb/s Stream #0:0: Video: h264 (High) (avc1 / 0x31637661), yuv420p, 720x576 Stream #0:1: Audio: aac (LC), 48000 Hz, stereo """; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Equal(TimeSpan.FromMinutes(10), duration); Assert.Equal("h264", codec); } [Fact] public void Should_Return_Zero_When_No_Duration() { var output = "Some random text without duration info."; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Empty(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); Assert.Equal(TimeSpan.Zero, duration); Assert.Equal(string.Empty, codec); } private static MainCommand.Settings CreateSettings(Action<MainCommand.Settings> configure = null) Loading Loading
src/VideoSlice.Cli/MainCommand.cs +215 −23 Original line number Diff line number Diff line using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; using shRabbit.Base.IO.Directories; using shRabbit.Base.IO.Files; using Spectre.Console; using Spectre.Console.Cli; Loading Loading @@ -44,7 +46,7 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> public string SliceTime { get; set; } = "30m"; [CommandOption("-a|--hwaccel")] [Description("FFmpeg硬件解码加速名称(如cuda/dxva2/qsv),留空则不启用。")] [Description("FFmpeg硬件解码加速名称(如cuda/dxva2/qsv);cuda会自动选用对应cuvid解码器,留空则不启用。")] public string HwAccel { get; set; } = string.Empty; [CommandOption("-n|--no-exec")] Loading @@ -52,42 +54,159 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> public bool NoExecute { get; set; } = false; } private static readonly Dictionary<string, string> CuvidDecoders = new(StringComparer.OrdinalIgnoreCase) { ["h264"] = "h264_cuvid", ["hevc"] = "hevc_cuvid", ["av1"] = "av1_cuvid", ["vp9"] = "vp9_cuvid", ["mpeg2video"] = "mpeg2_cuvid", }; /// <summary> /// 生成 ffmpeg 调用参数。 /// 生成 ffmpeg 调用参数。每个时间点生成一条命令,通过 -ss 输入快速定位, /// 只读取目标点附近的关键帧段,避免解码整段视频导致长时间等待。 /// </summary> /// <param name="settings"></param> /// <param name="file"></param> /// <param name="dir"></param> /// <param name="file">视频文件路径。</param> /// <param name="duration">视频时长。</param> /// <param name="codec">视频编码器名称(如 h264/hevc/av1)。</param> /// <returns>两层数组,第一次是每个命令,第二次是命令中的参数。</returns> public static IEnumerable<string[]> GenerateFfMpegCommand(Settings settings, IFile file, IDirectory dir) public static IEnumerable<string[]> GenerateFfMpegCommand(Settings settings, string file, TimeSpan duration, string codec) { var time = SimpleTimeSpanExpress.ToTimeSpan(settings.SliceTime); var interval = SimpleTimeSpanExpress.ToTimeSpan(settings.SliceTime); if (interval <= TimeSpan.Zero) throw new ArgumentException($"无效的分片间隔:'{settings.SliceTime}'。", nameof(settings)); foreach (var f in GetFiles(settings, dir)) { var output = Path.Join(Path.Combine(settings.Directory, settings.Disc), $"{Path.GetFileName(f)}_%03d.{settings.Format}"); var pointCount = duration > TimeSpan.Zero ? Math.Max(1, (int)Math.Ceiling(duration.TotalSeconds / interval.TotalSeconds)) : 1; var width = Math.Max(3, pointCount.ToString().Length); var outputDir = Path.Combine(settings.Directory, settings.Disc); var baseName = Path.GetFileName(file); for (var i = 0; i < pointCount; i++) { var args = new List<string>(7); if (!string.IsNullOrEmpty(settings.HwAccel)) { if (settings.HwAccel == "cuda" && CuvidDecoders.TryGetValue(codec, out var cuvid)) { args.Add("-c:v"); args.Add(cuvid); } else { args.Add("-hwaccel"); args.Add(settings.HwAccel); } } // 只解码关键帧,避免大间隔时解码全部帧导致长时间等待 args.Add("-skip_frame"); args.Add("nokey"); args.Add("-ss"); args.Add((i * interval.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture)); args.Add("-i"); args.Add($"\"{f}\""); args.Add("-vf"); args.Add($"\"fps=1/{time.TotalSeconds}\""); args.Add($"\"{output}\""); args.Add($"\"{file}\""); args.Add("-frames:v"); args.Add("1"); args.Add($"\"{Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")}\""); yield return args.ToArray(); } } /// <summary> /// 通过 ffmpeg 探测视频时长与编码器名称。 /// </summary> /// <param name="ffmpegPath">ffmpeg 可执行文件路径。</param> /// <param name="file">视频文件路径。</param> /// <param name="verbose">输出日志记录</param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ProbeMedia(string ffmpegPath, string file, bool verbose) { var si = new ProcessStartInfo { FileName = ffmpegPath, RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using var process = new Process { StartInfo = si }; process.StartInfo.ArgumentList.Add("-hide_banner"); process.StartInfo.ArgumentList.Add("-i"); process.StartInfo.ArgumentList.Add(file); ShowStartInfo(si, "启动探测:"); if (!process.Start()) throw new InvalidOperationException($"无法启动 ffmpeg:'{ffmpegPath}'。"); var stderr = process.StandardError.ReadToEnd(); if (!process.WaitForExit(TimeSpan.FromSeconds(15))) { process.Kill(); throw new InvalidOperationException($"探测视频信息超时:'{file}'。"); } return ParseProbeOutput(stderr, verbose); } /// <summary> /// 解析 ffmpeg -i 输出中的时长与视频编码器名称。 /// </summary> /// <param name="output">ffmpeg -i 的标准错误输出。</param> /// <param name="verbose"></param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose) { if (verbose) AnsiConsole.MarkupLineInterpolated($"[grey]{output}[/]"); var duration = TimeSpan.Zero; var codec = string.Empty; foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { if (duration == TimeSpan.Zero) { var dIndex = line.IndexOf("Duration:", StringComparison.OrdinalIgnoreCase); if (dIndex >= 0) { var rest = line[(dIndex + "Duration:".Length)..].TrimStart(); var end = rest.IndexOf(','); var text = end >= 0 ? rest[..end] : rest; if (TimeSpan.TryParse(text, CultureInfo.InvariantCulture, out var d)) duration = d; } } if (codec.Length == 0) { var vIndex = line.IndexOf("Video:", StringComparison.OrdinalIgnoreCase); if (vIndex >= 0) { var rest = line[(vIndex + "Video:".Length)..].TrimStart(); var space = rest.IndexOf(' '); codec = space >= 0 ? rest[..space] : rest; } } } if (verbose) AnsiConsole.MarkupLineInterpolated($"→ [Aquamarine1_1]{duration}, {codec}[/]"); return (duration, codec); } public static IEnumerable<string> GetFiles(Settings settings, IDirectory dir) { if (settings.File is { } s) yield break; Loading @@ -110,13 +229,86 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> { if (settings.NoExecute) { foreach (var arr in GenerateFfMpegCommand(settings, FileBridge.Default, DirectoryBridge.Default)) DirectoryBridge.Default.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) { var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) { AnsiConsole.WriteLine($"{settings.FfMpegPath} {string.Join('\u0020', arr)}"); } } return 0; } var tasks = new List<Task>(); var semaphore = new SemaphoreSlim((int)settings.ParallelNumber); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) { AnsiConsole.WriteLine($"找到文件:{f}"); var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); var count = 0; foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) { var si = new ProcessStartInfo(settings.FfMpegPath, arr); ShowStartInfo(si, $"创建的启动 [[{count}]]:"); tasks.Add(ExecuteSemaphore(semaphore, si, cancellationToken)); count++; } } await Task.WhenAll(tasks); return 0; } public static void ShowStartInfo(ProcessStartInfo si, string header) { var tree = new Tree(header); tree.AddNode($"启动程序:[cyan]{Markup.Escape(si.FileName)}[/]"); tree.AddNode($"参数:[green]{Markup.Escape(ShowArray(si.ArgumentList))}[/]"); tree.AddNode($"工作路径:[gray]{Markup.Escape(si.WorkingDirectory)}[/]"); AnsiConsole.Write(tree); AnsiConsole.WriteLine(); } public static string ShowArray<T>(IEnumerable<T> array) { var sb = new StringBuilder(); sb.Append("[ "); sb.Append(string.Join(", ", array)); sb.Append(" ]"); return sb.ToString(); } public async Task ExecuteSemaphore(SemaphoreSlim semaphore, ProcessStartInfo startInfo, CancellationToken cancellationToken) { await semaphore.WaitAsync(cancellationToken); try { var process = Process.Start(startInfo)!; await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) { var tree = new Tree($"[red]出现错误 ({startInfo.FileName} {startInfo.Arguments})[/]"); tree.AddNode($"返回值:[red]{process.ExitCode}[/]"); tree.AddNode("错误流") .AddNode(await process.StandardError.ReadToEndAsync(cancellationToken)); tree.AddNode("输出流") .AddNode(await process.StandardOutput.ReadToEndAsync(cancellationToken)); AnsiConsole.Write(tree); } } finally { semaphore.Release(); } } }
src/VideoSlice.Cli/Program.cs +3 −6 Original line number Diff line number Diff line using Spectre.Console.Cli; using Spectre.Console; using Spectre.Console.Cli; namespace VideoSlice.Cli; Loading @@ -6,11 +7,7 @@ class Program { static int Main(string[] args) { var app = new CommandApp(); app.Configure(c => { c.AddCommand<MainCommand>("gen"); }); var app = new CommandApp<MainCommand>(); return app.Run(args); } } No newline at end of file
src/VideoSlice.Cli/VideoSlice.Cli.csproj +0 −2 Original line number Diff line number Diff line Loading @@ -9,10 +9,8 @@ </PropertyGroup> <ItemGroup> <PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" /> <PackageReference Include="JetBrains.Annotations" Version="2026.2.0" /> <PackageReference Include="shRabbit.Base" Version="1.12.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="Spectre.Console.Cli" Version="0.55.0" /> </ItemGroup> Loading
src/VideoSlice.Tests/MainCommandTest.cs +114 −64 Original line number Diff line number Diff line Loading @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using JetBrains.Annotations; using shRabbit.Base.IO.Directories; using shRabbit.Base.IO.Files; using VideoSlice.Cli; using Xunit; using Assert = Xunit.Assert; Loading Loading @@ -69,113 +68,164 @@ public class MainCommandTest } [Fact] public void Should_Generate_Command_Per_File() public void Should_Generate_Point_Commands() { var settings = CreateSettings(); var dir = new TestDirectoryBridge( Path.Combine("videos", "a.mp4"), Path.Combine("videos", "b.mp4")); var file = Path.Combine("videos", "a.mp4"); var duration = TimeSpan.FromHours(2); // 2h / 30m = 4 个时间点:0/1800/3600/5400 var commands = MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir).ToArray(); var commands = MainCommand.GenerateFfMpegCommand(settings, file, duration, "h264").ToArray(); Assert.Collection(commands, c => Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")}\"" "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], c), c => Assert.Equal( [ "-ss", "1800", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_001.png")}\"" ], c), c => Assert.Equal( [ "-ss", "3600", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_002.png")}\"" ], c), c => Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "b.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "b.mp4_%03d.png")}\"" "-ss", "5400", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png")}\"" ], c)); } [Fact] public void Should_Generate_Command_With_Custom_Slice_Disc_And_Format() public void Should_Generate_Single_Point_When_Duration_Shorter_Than_Interval() { var settings = CreateSettings(s => var settings = CreateSettings(); var file = Path.Combine("videos", "a.mp4"); var duration = TimeSpan.FromMinutes(10); // 10m < 30m → 只抽取第 0 秒 var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, duration, "h264")); Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Use_Cuvid_Decoder_For_Av1_When_Cuda() { s.SliceTime = "1m"; s.Disc = "frames"; s.Format = "jpg"; }); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var settings = CreateSettings(s => s.HwAccel = "cuda"); var file = Path.Combine("videos", "a.mkv"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "av1")); Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/60\"", $"\"{Path.Join(Path.Combine("videos", "frames"), "a.mp4_%03d.jpg")}\"" "-c:v", "av1_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png")}\"" ], command); } [Fact] public void Should_Quote_File_And_Output_Paths() public void Should_Use_Cuvid_Decoder_For_H264_When_Cuda() { var settings = CreateSettings(s => s.VideoFormats = "mkv"); var fileName = "2026-07-30 20-49-18.mkv"; var dir = new TestDirectoryBridge(Path.Combine("videos", fileName)); var settings = CreateSettings(s => s.HwAccel = "cuda"); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "h264")); Assert.Equal( [ "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", fileName)}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), $"{fileName}_%03d.png")}\"" "-c:v", "h264_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Prepend_Hwaccel_When_Set() public void Should_Use_Generic_Hwaccel_When_Codec_Has_No_Cuvid_Decoder() { var settings = CreateSettings(s => s.HwAccel = "cuda"); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "mpeg4")); Assert.Equal( [ "-hwaccel", "cuda", "-skip_frame", "nokey", "-i", $"\"{Path.Combine("videos", "a.mp4")}\"", "-vf", "\"fps=1/1800\"", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")}\"" "-hwaccel", "cuda", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Generate_Empty_When_No_Files() public void Should_Use_Given_Hwaccel_For_Non_Cuda() { var settings = CreateSettings(s => s.File = Path.Combine("videos", "a.mp4")); var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4")); var settings = CreateSettings(s => s.HwAccel = "dxva2"); var file = Path.Combine("videos", "a.mp4"); var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromMinutes(10), "av1")); Assert.Equal( [ "-hwaccel", "dxva2", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" ], command); } [Fact] public void Should_Throw_On_Invalid_SliceTime() { var settings = CreateSettings(s => s.SliceTime = "0s"); var file = Path.Combine("videos", "a.mp4"); Assert.Throws<ArgumentException>(() => MainCommand.GenerateFfMpegCommand(settings, file, TimeSpan.FromHours(2), "h264").ToArray()); } [Fact] public void Should_Parse_Duration_And_Codec() { var output = """ Input #0, matroska,webm, from 'K:\Administrator\Videos\x.mkv': Metadata: ENCODER : Lavf61.7.100 Duration: 05:51:30.37, start: 0.000000, bitrate: 4272 kb/s Stream #0:0: Video: av1 (libaom-av1) (Main), yuv420p(pc, bt709), 1920x1080 """; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Equal(new TimeSpan(0, 5, 51, 30, 370), duration); Assert.Equal("av1", codec); } [Fact] public void Should_Parse_Codec_Of_Second_Video_Stream() { var output = """ Duration: 00:10:00.00, start: 0.000000, bitrate: 1000 kb/s Stream #0:0: Video: h264 (High) (avc1 / 0x31637661), yuv420p, 720x576 Stream #0:1: Audio: aac (LC), 48000 Hz, stereo """; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Equal(TimeSpan.FromMinutes(10), duration); Assert.Equal("h264", codec); } [Fact] public void Should_Return_Zero_When_No_Duration() { var output = "Some random text without duration info."; var (duration, codec) = MainCommand.ParseProbeOutput(output, true); Assert.Empty(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir)); Assert.Equal(TimeSpan.Zero, duration); Assert.Equal(string.Empty, codec); } private static MainCommand.Settings CreateSettings(Action<MainCommand.Settings> configure = null) Loading