Commit d5a263f5 authored by shrabbit's avatar shrabbit
Browse files

优化输出和速度。

parent 3b640fed
Loading
Loading
Loading
Loading
+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;

@@ -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")]
@@ -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;
@@ -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();
        }
    }
}
+3 −6
Original line number Diff line number Diff line
using Spectre.Console.Cli;
using Spectre.Console;
using Spectre.Console.Cli;

namespace VideoSlice.Cli;

@@ -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
+0 −2
Original line number Diff line number Diff line
@@ -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>

+114 −64
Original line number Diff line number Diff line
@@ -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;
@@ -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)