Loading src/VideoSlice.Cli/MainCommand.cs +95 −67 Original line number Diff line number Diff line Loading @@ -107,10 +107,10 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> args.Add("-ss"); args.Add((i * interval.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture)); args.Add("-i"); args.Add($"\"{file}\""); args.Add(file); args.Add("-frames:v"); args.Add("1"); args.Add($"\"{Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")}\""); args.Add(Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")); yield return args.ToArray(); } Loading Loading @@ -143,7 +143,6 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> process.StartInfo.ArgumentList.Add("-i"); process.StartInfo.ArgumentList.Add(file); if (verbose) ShowStartInfo(si, "启动探测:"); if (!process.Start()) Loading @@ -166,7 +165,7 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> /// <param name="output">ffmpeg -i 的标准错误输出。</param> /// <param name="verbose"></param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose) public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose = false) { if (verbose) AnsiConsole.MarkupLineInterpolated($"[grey]{output}[/]"); Loading Loading @@ -208,6 +207,27 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> return (duration, codec); } 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 static IEnumerable<string> GetFiles(Settings settings, IDirectory dir) { if (settings.File is { } s) yield break; Loading @@ -228,88 +248,96 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { if (settings.NoExecute) { DirectoryBridge.Default.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); var dirBridge = DirectoryBridge.Default; dirBridge.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) var failed = 0; foreach (var f in GetFiles(settings, dirBridge)) { var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); var commands = GenerateFfMpegCommand(settings, f, duration, codec).ToArray(); foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) if (settings.NoExecute) { AnsiConsole.WriteLine($"{settings.FfMpegPath} {string.Join('\u0020', arr)}"); } } return 0; foreach (var arr in commands) AnsiConsole.WriteLine(FormatCommand(settings.FfMpegPath, arr)); continue; } 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 degree = settings.ParallelNumber == 0 ? 1 : (int)settings.ParallelNumber; var count = 0; foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) await Parallel.ForEachAsync(commands, new ParallelOptions { var si = new ProcessStartInfo(settings.FfMpegPath, arr); ShowStartInfo(si, $"创建的启动 [[{count}]]:"); tasks.Add(ExecuteSemaphore(semaphore, si, cancellationToken)); count++; } MaxDegreeOfParallelism = degree, CancellationToken = cancellationToken, }, async (arr, ct) => { if (await RunFfMpegAsync(settings.FfMpegPath, arr, ct) != 0) Interlocked.Increment(ref failed); }); } await Task.WhenAll(tasks); return 0; return failed > 0 ? 1 : 0; } public static void ShowStartInfo(ProcessStartInfo si, string header) /// <summary> /// 将命令参数格式化为可复制到终端的命令行文本,含空格的参数自动加引号。 /// </summary> private static string FormatCommand(string ffmpegPath, IEnumerable<string> args) { 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(); var parts = args.Select(a => a.Contains(' ') || a.Contains('"') ? $"\"{a}\"" : a); return $"{ffmpegPath} {string.Join('\u0020', parts)}"; } public static string ShowArray<T>(IEnumerable<T> array) /// <summary> /// 执行一条 ffmpeg 命令并返回退出码。 /// </summary> private static async Task<int> RunFfMpegAsync(string ffmpegPath, string[] args, CancellationToken ct) { var sb = new StringBuilder(); sb.Append("[ "); sb.Append(string.Join(", ", array)); sb.Append(" ]"); var psi = new ProcessStartInfo { FileName = ffmpegPath, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, }; return sb.ToString(); } psi.ArgumentList.Add("-hide_banner"); foreach (var a in args) psi.ArgumentList.Add(a); public async Task ExecuteSemaphore(SemaphoreSlim semaphore, ProcessStartInfo startInfo, CancellationToken cancellationToken) { await semaphore.WaitAsync(cancellationToken); try { var process = Process.Start(startInfo)!; await process.WaitForExitAsync(cancellationToken); using var process = new Process { StartInfo = psi }; if (process.ExitCode != 0) if (!process.Start()) { 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); AnsiConsole.MarkupLineInterpolated($"[red]无法启动:[/] {FormatCommand(ffmpegPath, args)}"); return -1; } var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); var stderrTask = process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); var stderr = await stderrTask; await stdoutTask; var output = args[^1]; if (process.ExitCode == 0) { AnsiConsole.MarkupLineInterpolated($"[green]已导出:[/] {output}"); } finally else { semaphore.Release(); AnsiConsole.MarkupLineInterpolated($"[red]失败 ({process.ExitCode}):[/] {output}"); if (!string.IsNullOrWhiteSpace(stderr)) AnsiConsole.WriteLine(stderr.Trim()); } return process.ExitCode; } } src/VideoSlice.Tests/MainCommandTest.cs +18 −18 Original line number Diff line number Diff line Loading @@ -79,23 +79,23 @@ public class MainCommandTest Assert.Collection(commands, c => Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-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")}\"" "-ss", "1800", "-i", Path.Combine("videos", "a.mp4"), "-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")}\"" "-ss", "3600", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_002.png") ], c), c => Assert.Equal( [ "-ss", "5400", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png")}\"" "-ss", "5400", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png") ], c)); } Loading @@ -110,8 +110,8 @@ public class MainCommandTest Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -125,8 +125,8 @@ public class MainCommandTest Assert.Equal( [ "-c:v", "av1_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png")}\"" "-c:v", "av1_cuvid", "-ss", "0", "-i", Path.Combine("videos", "a.mkv"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png") ], command); } Loading @@ -140,8 +140,8 @@ public class MainCommandTest Assert.Equal( [ "-c:v", "h264_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-c:v", "h264_cuvid", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -155,8 +155,8 @@ public class MainCommandTest Assert.Equal( [ "-hwaccel", "cuda", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-hwaccel", "cuda", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -170,8 +170,8 @@ public class MainCommandTest Assert.Equal( [ "-hwaccel", "dxva2", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-hwaccel", "dxva2", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading Loading
src/VideoSlice.Cli/MainCommand.cs +95 −67 Original line number Diff line number Diff line Loading @@ -107,10 +107,10 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> args.Add("-ss"); args.Add((i * interval.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture)); args.Add("-i"); args.Add($"\"{file}\""); args.Add(file); args.Add("-frames:v"); args.Add("1"); args.Add($"\"{Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")}\""); args.Add(Path.Join(outputDir, $"{baseName}_{i.ToString($"D{width}")}.{settings.Format}")); yield return args.ToArray(); } Loading Loading @@ -143,7 +143,6 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> process.StartInfo.ArgumentList.Add("-i"); process.StartInfo.ArgumentList.Add(file); if (verbose) ShowStartInfo(si, "启动探测:"); if (!process.Start()) Loading @@ -166,7 +165,7 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> /// <param name="output">ffmpeg -i 的标准错误输出。</param> /// <param name="verbose"></param> /// <returns>时长与编码器名称。</returns> public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose) public static (TimeSpan Duration, string Codec) ParseProbeOutput(string output, bool verbose = false) { if (verbose) AnsiConsole.MarkupLineInterpolated($"[grey]{output}[/]"); Loading Loading @@ -208,6 +207,27 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> return (duration, codec); } 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 static IEnumerable<string> GetFiles(Settings settings, IDirectory dir) { if (settings.File is { } s) yield break; Loading @@ -228,88 +248,96 @@ public class MainCommand : AsyncCommand<MainCommand.Settings> protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { if (settings.NoExecute) { DirectoryBridge.Default.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); var dirBridge = DirectoryBridge.Default; dirBridge.CreateDirectory(Path.Combine(settings.Directory, settings.Disc)); foreach (var f in GetFiles(settings, DirectoryBridge.Default)) var failed = 0; foreach (var f in GetFiles(settings, dirBridge)) { var (duration, codec) = ProbeMedia(settings.FfMpegPath, f, !settings.NoExecute); var commands = GenerateFfMpegCommand(settings, f, duration, codec).ToArray(); foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) if (settings.NoExecute) { AnsiConsole.WriteLine($"{settings.FfMpegPath} {string.Join('\u0020', arr)}"); } } return 0; foreach (var arr in commands) AnsiConsole.WriteLine(FormatCommand(settings.FfMpegPath, arr)); continue; } 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 degree = settings.ParallelNumber == 0 ? 1 : (int)settings.ParallelNumber; var count = 0; foreach (var arr in GenerateFfMpegCommand(settings, f, duration, codec)) await Parallel.ForEachAsync(commands, new ParallelOptions { var si = new ProcessStartInfo(settings.FfMpegPath, arr); ShowStartInfo(si, $"创建的启动 [[{count}]]:"); tasks.Add(ExecuteSemaphore(semaphore, si, cancellationToken)); count++; } MaxDegreeOfParallelism = degree, CancellationToken = cancellationToken, }, async (arr, ct) => { if (await RunFfMpegAsync(settings.FfMpegPath, arr, ct) != 0) Interlocked.Increment(ref failed); }); } await Task.WhenAll(tasks); return 0; return failed > 0 ? 1 : 0; } public static void ShowStartInfo(ProcessStartInfo si, string header) /// <summary> /// 将命令参数格式化为可复制到终端的命令行文本,含空格的参数自动加引号。 /// </summary> private static string FormatCommand(string ffmpegPath, IEnumerable<string> args) { 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(); var parts = args.Select(a => a.Contains(' ') || a.Contains('"') ? $"\"{a}\"" : a); return $"{ffmpegPath} {string.Join('\u0020', parts)}"; } public static string ShowArray<T>(IEnumerable<T> array) /// <summary> /// 执行一条 ffmpeg 命令并返回退出码。 /// </summary> private static async Task<int> RunFfMpegAsync(string ffmpegPath, string[] args, CancellationToken ct) { var sb = new StringBuilder(); sb.Append("[ "); sb.Append(string.Join(", ", array)); sb.Append(" ]"); var psi = new ProcessStartInfo { FileName = ffmpegPath, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, }; return sb.ToString(); } psi.ArgumentList.Add("-hide_banner"); foreach (var a in args) psi.ArgumentList.Add(a); public async Task ExecuteSemaphore(SemaphoreSlim semaphore, ProcessStartInfo startInfo, CancellationToken cancellationToken) { await semaphore.WaitAsync(cancellationToken); try { var process = Process.Start(startInfo)!; await process.WaitForExitAsync(cancellationToken); using var process = new Process { StartInfo = psi }; if (process.ExitCode != 0) if (!process.Start()) { 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); AnsiConsole.MarkupLineInterpolated($"[red]无法启动:[/] {FormatCommand(ffmpegPath, args)}"); return -1; } var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); var stderrTask = process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); var stderr = await stderrTask; await stdoutTask; var output = args[^1]; if (process.ExitCode == 0) { AnsiConsole.MarkupLineInterpolated($"[green]已导出:[/] {output}"); } finally else { semaphore.Release(); AnsiConsole.MarkupLineInterpolated($"[red]失败 ({process.ExitCode}):[/] {output}"); if (!string.IsNullOrWhiteSpace(stderr)) AnsiConsole.WriteLine(stderr.Trim()); } return process.ExitCode; } }
src/VideoSlice.Tests/MainCommandTest.cs +18 −18 Original line number Diff line number Diff line Loading @@ -79,23 +79,23 @@ public class MainCommandTest Assert.Collection(commands, c => Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-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")}\"" "-ss", "1800", "-i", Path.Combine("videos", "a.mp4"), "-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")}\"" "-ss", "3600", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_002.png") ], c), c => Assert.Equal( [ "-ss", "5400", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png")}\"" "-ss", "5400", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_003.png") ], c)); } Loading @@ -110,8 +110,8 @@ public class MainCommandTest Assert.Equal( [ "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -125,8 +125,8 @@ public class MainCommandTest Assert.Equal( [ "-c:v", "av1_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png")}\"" "-c:v", "av1_cuvid", "-ss", "0", "-i", Path.Combine("videos", "a.mkv"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mkv_000.png") ], command); } Loading @@ -140,8 +140,8 @@ public class MainCommandTest Assert.Equal( [ "-c:v", "h264_cuvid", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-c:v", "h264_cuvid", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -155,8 +155,8 @@ public class MainCommandTest Assert.Equal( [ "-hwaccel", "cuda", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-hwaccel", "cuda", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading @@ -170,8 +170,8 @@ public class MainCommandTest Assert.Equal( [ "-hwaccel", "dxva2", "-ss", "0", "-i", $"\"{file}\"", "-frames:v", "1", $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png")}\"" "-hwaccel", "dxva2", "-ss", "0", "-i", Path.Combine("videos", "a.mp4"), "-frames:v", "1", Path.Join(Path.Combine("videos", "output"), "a.mp4_000.png") ], command); } Loading