Commit 3a0e504c authored by shrabbit's avatar shrabbit
Browse files

实验性直接操作ffmpeg。

parent ac854593
Loading
Loading
Loading
Loading
+26 −13
Original line number Diff line number Diff line
@@ -43,6 +43,10 @@ public class MainCommand : AsyncCommand<MainCommand.Settings>
        [Description("将视频按照时长切分的间隔时间(默认30m)。")]
        public string SliceTime { get; set; } = "30m";

        [CommandOption("-a|--hwaccel")]
        [Description("FFmpeg硬件解码加速名称(如cuda/dxva2/qsv),留空则不启用。")]
        public string HwAccel { get; set; } = string.Empty;

        [CommandOption("-n|--no-exec")]
        [Description("只输出命令列表,不运行命令。")]
        public bool NoExecute { get; set; } = false;
@@ -61,13 +65,26 @@ public class MainCommand : AsyncCommand<MainCommand.Settings>

        foreach (var f in GetFiles(settings, dir))
        {
            yield return [
                "-i", 
                f, 
                "-vf", 
                $"\"fps=1/{time.TotalSeconds}\"",
                Path.Join(Path.Combine(settings.Directory, settings.Disc), $"{Path.GetFileName(f)}_%03d.{settings.Format}")
            ];
            var output = Path.Join(Path.Combine(settings.Directory, settings.Disc), $"{Path.GetFileName(f)}_%03d.{settings.Format}");

            var args = new List<string>(7);

            if (!string.IsNullOrEmpty(settings.HwAccel))
            {
                args.Add("-hwaccel");
                args.Add(settings.HwAccel);
            }

            // 只解码关键帧,避免大间隔时解码全部帧导致长时间等待
            args.Add("-skip_frame");
            args.Add("nokey");
            args.Add("-i");
            args.Add($"\"{f}\"");
            args.Add("-vf");
            args.Add($"\"fps=1/{time.TotalSeconds}\"");
            args.Add($"\"{output}\"");

            yield return args.ToArray();
        }
    }

@@ -90,15 +107,11 @@ public class MainCommand : AsyncCommand<MainCommand.Settings>
    }

    protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
    {
        if (settings.NoExecute)
    {
        foreach (var arr in GenerateFfMpegCommand(settings, FileBridge.Default, DirectoryBridge.Default))
        {
            AnsiConsole.WriteLine($"{settings.FfMpegPath} {string.Join('\u0020', arr)}");
        }
            return 0;
        }
        
        return 0;
    }
+5 −1
Original line number Diff line number Diff line
@@ -6,7 +6,11 @@ class Program
{
    static int Main(string[] args)
    {
        var app = new CommandApp<MainCommand>();
        var app = new CommandApp();
        app.Configure(c =>
        {
            c.AddCommand<MainCommand>("gen");
        });
        return app.Run(args);
    }
}
 No newline at end of file
+3 −0
Original line number Diff line number Diff line
@@ -5,11 +5,14 @@
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </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>

+348 −0
Original line number Diff line number Diff line
// using FFmpeg.AutoGen;
// using SixLabors.ImageSharp;
// using SixLabors.ImageSharp.PixelFormats;
// using static FFmpeg.AutoGen.ffmpeg;
//
// namespace VideoSlice.Cli;
//
// public class VideoSlice
// {
//     public unsafe void Run(string videoPath, TimeSpan slice, string outputPath)
//     {
//         AVFormatContext* ctx = null;
//
//         try
//         {
//             // 1. 分配上下文
//             ctx = avformat_alloc_context();
//             if (ctx == null) throw new VideoSliceException("创建媒体上下文失败");
//
//             // 2. 打开输入文件
//             var result = avformat_open_input(&ctx, videoPath, null, null);
//             if (result < 0)
//                 throw new VideoSliceException($"打开输入文件失败:{result}")
//                 {
//                     HResult = result
//                 };
//
//             // 3. 读取流信息(关键步骤!)
//             result = avformat_find_stream_info(ctx, null);
//             if (result < 0)
//                 throw new VideoSliceException($"查找流信息失败:{result}")
//                 {
//                     HResult = result
//                 };
//
//             // 4. 查找视频流
//             var videoStreamIndex = -1;
//             for (var i = 0; i < ctx->nb_streams; i++)
//                 if (ctx->streams[i]->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO)
//                 {
//                     videoStreamIndex = i;
//                     break;
//                 }
//
//             if (videoStreamIndex == -1) throw new VideoSliceException("未找到视频流");
//
//             // 5. 获取视频流信息
//             var videoStream = ctx->streams[videoStreamIndex];
//             var codecParams = videoStream->codecpar;
//
//             // 6. 查找解码器
//             var decoder = avcodec_find_decoder(codecParams->codec_id);
//             if (decoder == null) throw new VideoSliceException($"找不到解码器:{codecParams->codec_id}");
//
//             // 7. 创建解码器上下文
//             var codecCtx = avcodec_alloc_context3(decoder);
//             if (codecCtx == null) throw new VideoSliceException("创建解码器上下文失败");
//
//             // 8. 将编解码器参数复制到解码器上下文
//             result = avcodec_parameters_to_context(codecCtx, codecParams);
//             if (result < 0)
//             {
//                 avcodec_free_context(&codecCtx);
//                 throw new VideoSliceException($"复制编解码器参数失败:{result}")
//                 {
//                     HResult = result
//                 };
//             }
//
//             // 9. 打开解码器
//             result = avcodec_open2(codecCtx, decoder, null);
//             if (result < 0)
//             {
//                 avcodec_free_context(&codecCtx);
//                 throw new VideoSliceException($"打开解码器失败:{result}")
//                 {
//                     HResult = result
//                 };
//             }
//
//             // 现在可以开始读取和解码帧了
//             Console.WriteLine($"视频信息:");
//             Console.WriteLine($"  宽度: {codecCtx->width}");
//             Console.WriteLine($"  高度: {codecCtx->height}");
//             Console.WriteLine($"  帧率: {videoStream->avg_frame_rate.num}/{videoStream->avg_frame_rate.den}");
//             Console.WriteLine($"  时长: {ctx->duration / (double)AV_TIME_BASE} 秒");
//
//             // 10. 在这里进行视频切片处理...
//             ProcessVideoSlice(ctx, codecCtx, videoStreamIndex, slice, outputPath);
//
//             // 清理资源
//             avcodec_free_context(&codecCtx);
//         }
//         finally
//         {
//             // 关闭输入并释放上下文
//             if (ctx != null) avformat_close_input(&ctx);
//         }
//     }
//
//     private unsafe void ProcessVideoSlice(AVFormatContext* ctx, AVCodecContext* codecCtx, int videoStreamIndex,
//         TimeSpan sliceTime, string outputPath)
//     {
//         // 1. 计算初始时间戳 (微秒)
//         var currentTimestamp = (long)sliceTime.TotalSeconds * AV_TIME_BASE;
//
//         // 用于保存最后一次成功解码的帧,以处理超出时长的情况
//         var lastFrame = av_frame_alloc();
//         var hasLastFrame = false;
//
//         try
//         {
//             while (true)
//             {
//                 // 2. 尝试定位到目标时间点 (尽量向后退到最近的关键帧)
//                 var ret = avformat_seek_file(ctx, -1, long.MinValue, currentTimestamp, long.MaxValue,
//                     AVSEEK_FLAG_BACKWARD);
//                 if (ret < 0)
//                     // 定位失败,抛出异常并携带 FFmpeg 错误码
//                     throw new VideoSliceException("Video stream is not seekable or seek failed.")
//                     {
//                         HResult = ret
//                     };
//
//                 // 刷新解码器内部缓冲区,防止拿到跳转之前的残留帧
//                 avcodec_flush_buffers(codecCtx);
//
//                 var frameProcessed = false;
//                 var endOfStream = false;
//
//                 var packet = av_packet_alloc();
//                 var frame = av_frame_alloc();
//
//                 try
//                 {
//                     // 3. 读取并解码帧
//                     while (!endOfStream)
//                     {
//                         ret = av_read_frame(ctx, packet);
//                         if (ret < 0)
//                         {
//                             // 读到文件末尾或读取错误
//                             endOfStream = true;
//
//                             // 发送空包,冲刷解码器内部缓存的最后几帧
//                             avcodec_send_packet(codecCtx, null);
//                         }
//                         else
//                         {
//                             // 过滤:只处理视频流
//                             if (packet->stream_index != videoStreamIndex)
//                             {
//                                 av_packet_unref(packet);
//                                 continue;
//                             }
//
//                             // 将压缩包发送给解码器
//                             ret = avcodec_send_packet(codecCtx, packet);
//                             av_packet_unref(packet); // 立即释放包内部资源
//
//                             if (ret < 0 && ret != AVERROR(EAGAIN))
//                             {
//                                 // 解码发送出错
//                                 endOfStream = true;
//                                 continue;
//                             }
//                         }
//
//                         // 接收解码后的原始帧
//                         while (true)
//                         {
//                             ret = avcodec_receive_frame(codecCtx, frame);
//                             if (ret == AVERROR(EAGAIN) || ret == AVERROR(AVERROR_EOF))
//                             {
//                                 break; // 需要更多包,或解码器已清空
//                             }
//                             else if (ret < 0)
//                             {
//                                 endOfStream = true; // 解码出错
//                                 break;
//                             }
//
//                             // --- 成功获取到视频帧 ---
//
//                             // 将当前帧复制保存为 lastFrame (用于处理超出时长的情况)
//                             // 必须先 unref 之前的,再 ref 当前的
//                             av_frame_unref(lastFrame);
//                             av_frame_ref(lastFrame, frame);
//                             hasLastFrame = true;
//
//                             // 获取当前帧的时间戳 (基于 AV_TIME_BASE)
//                             long frameTimeUs = 0;
//                             if (frame->pts != AV_NOPTS_VALUE)
//                             {
//                                 // 将流的 pts 转换为全局时间基 (微秒)
//                                 var timeBase = ctx->streams[videoStreamIndex]->time_base;
//                                 frameTimeUs = (long)(frame->pts * timeBase.num * 1000000.0 / timeBase.den);
//                             }
//
//                             // 如果当前帧时间 >= 目标时间,或者已经是文件末尾了
//                             if (frameTimeUs >= currentTimestamp || endOfStream)
//                             {
//                                 // 4. 调用处理帧的方法
//                                 ProcessFrame(frame, outputPath);
//                                 frameProcessed = true;
//
//                                 // 跳出接收循环和读取循环,准备进入下一个 slice 间隔
//                                 endOfStream = true;
//                                 break;
//                             }
//                         }
//                     } // end while(!endOfStream)
//
//                     // 5. 判断是否超出视频时长
//                     if (!frameProcessed)
//                     {
//                         // 如果在本次循环中没有处理任何帧 (说明 av_read_frame 直接返回了 EOF)
//                         // 并且我们之前有保存过最后一帧
//                         if (hasLastFrame)
//                             // 只保留视频的最后一帧
//                             ProcessFrame(lastFrame, outputPath);
//                         break; // 跳出 while(true),结束切片处理
//                     }
//                 }
//                 finally
//                 {
//                     if (frame != null) av_frame_free(&frame);
//                     if (packet != null) av_packet_free(&packet);
//                 }
//
//                 // 6. 推进到下一个时间间隔
//                 currentTimestamp += (long)sliceTime.TotalSeconds * AV_TIME_BASE;
//             }
//         }
//         finally
//         {
//             if (lastFrame != null) av_frame_free(&lastFrame);
//         }
//     }
//
//     public unsafe void ProcessFrame(AVFrame* frame, string outputPath)
//     {
//     }
//
//     public unsafe Image<Rgba32> ProcessFrameFormat(AVFrame* frame)
//     {
// // 1. 校验输入帧
//         if (frame == null || frame->width <= 0 || frame->height <= 0)
//             throw new ArgumentException("Invalid frame data.");
//
//         var width = frame->width;
//         var height = frame->height;
//
//         // 目标格式:RGBA32 (按字节排列为 R, G, B, A)
//         var targetPixelFormat = AVPixelFormat.AV_PIX_FMT_RGBA;
//
//         // 2. 初始化 SwContext (图像缩放与颜色空间转换上下文)
//         // 如果你的源格式固定是 YUV420P,可以直接写死;为了兼容性,最好从 frame 获取
//         var srcPixelFormat = (AVPixelFormat)frame->format;
//
//         var swsContext = sws_getContext(
//             width, height, srcPixelFormat,
//             width, height, targetPixelFormat,
//             2, // 缩放算法,不缩放的话用 BILINEAR 即可
//             null, null, null);
//
//         if (swsContext == null) throw new InvalidOperationException("Could not initialize the conversion context.");
//
//         // 3. 分配非托管内存缓冲区用于接收转换后的 RGBA 数据
//         // 每个像素 4 字节 (R, G, B, A)
//         var bufferSize = width * height * 4;
//         var dstBuffer = (byte*)av_malloc((ulong)bufferSize);
//
//         // sws_scale 需要一个指针数组,指向每一行的数据
//         var dstData = new byte*[1];
//         var dstLinesize = new int[1];
//
//         // ImageSharp 期望的行步长通常就是 width * 4
//         dstLinesize[0] = width * 4;
//
//         try
//         {
//             // 固定托管数组,以便获取指向它的指针并传递给 FFmpeg
//             fixed (byte** dstDataPtr = &dstData[0])
//             {
//                 // 将目标缓冲区地址赋给数组的第一项
//                 dstData[0] = dstBuffer;
//
//                 // 4. 执行图像转换
//                 // srcData: frame->data (指向 Y/U/V 平面的指针)
//                 // srcLinesize: frame->linesize (每个平面的行步长)
//                 var convertedHeight = sws_scale(
//                     swsContext,
//                     frame->data, frame->linesize,
//                     0, height,
//                     dstDataPtr, dstLinesize);
//
//                 if (convertedHeight <= 0) throw new InvalidOperationException("Image conversion failed.");
//
//                 // 5. 将非托管内存数据拷贝到托管 Span,然后加载到 ImageSharp
//                 // 创建一个临时的 byte 数组来承载数据
//                 var managedBytes = new byte[bufferSize];
//                 // 使用 Marshal.Copy 或直接用 Span 拷贝 (推荐 Span,性能好)
//                 // 注意:因为 dstBuffer 是 byte*,可以直接用 C# 8.0+ 的新语法包装为 Span
//                 var sourceSpan = new Span<byte>(dstBuffer, bufferSize);
//                 sourceSpan.CopyTo(managedBytes);
//
//                 // 6. 使用 ImageSharp 从字节数组加载图像
//                 // ImageSharp 的 Image.Load 需要流或文件路径,这里用 MemoryStream 包装
//                 using (var ms = new MemoryStream(managedBytes))
//                 {
//                     // 由于传入的是原始 RGBA 数据,ImageSharp 默认无法识别格式,
//                     // 需要使用自定义解码器指定大小和位深
//                     var config = Configuration.Default;
//                     // var image = Image.Load<Rgba32>(config, ms);
//
//                     // ⚠️ 重要提示:
//                     // Image.Load 默认会将数据当作 PNG/JPEG 等格式去解码。
//                     // 如果直接传 RGBA 原始字节流,它会报错。
//                     // 正确加载原始 RGBA 字节的方式见下方“补充说明”。
//
//                     return image;
//                 }
//             }
//         }
//         finally
//         {
//             // 7. 清理非托管资源
//             if (dstBuffer != null) av_free(dstBuffer);
//             if (swsContext != null) sws_freeContext(swsContext);
//         }
//     }
// }
//
// public class VideoSliceException : Exception
// {
//     public VideoSliceException()
//     {
//     }
//
//     public VideoSliceException(string message) : base(message)
//     {
//     }
//
//     public VideoSliceException(string message, Exception inner) : base(message, inner)
//     {
//     }
// }
 No newline at end of file
+55 −6
Original line number Diff line number Diff line
@@ -81,19 +81,23 @@ public class MainCommandTest
        Assert.Collection(commands,
            c => Assert.Equal(
            [
                "-skip_frame",
                "nokey",
                "-i",
                Path.Combine("videos", "a.mp4"),
                $"\"{Path.Combine("videos", "a.mp4")}\"",
                "-vf",
                "\"fps=1/1800\"",
                Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")
                $"\"{Path.Join(Path.Combine("videos", "output"), "a.mp4_%03d.png")}\""
            ], c),
            c => Assert.Equal(
            [
                "-skip_frame",
                "nokey",
                "-i",
                Path.Combine("videos", "b.mp4"),
                $"\"{Path.Combine("videos", "b.mp4")}\"",
                "-vf",
                "\"fps=1/1800\"",
                Path.Join(Path.Combine("videos", "output"), "b.mp4_%03d.png")
                $"\"{Path.Join(Path.Combine("videos", "output"), "b.mp4_%03d.png")}\""
            ], c));
    }

@@ -112,11 +116,56 @@ public class MainCommandTest

        Assert.Equal(
        [
            "-skip_frame",
            "nokey",
            "-i",
            Path.Combine("videos", "a.mp4"),
            $"\"{Path.Combine("videos", "a.mp4")}\"",
            "-vf",
            "\"fps=1/60\"",
            Path.Join(Path.Combine("videos", "frames"), "a.mp4_%03d.jpg")
            $"\"{Path.Join(Path.Combine("videos", "frames"), "a.mp4_%03d.jpg")}\""
        ], command);
    }

    [Fact]
    public void Should_Quote_File_And_Output_Paths()
    {
        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 command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir));

        Assert.Equal(
        [
            "-skip_frame",
            "nokey",
            "-i",
            $"\"{Path.Combine("videos", fileName)}\"",
            "-vf",
            "\"fps=1/1800\"",
            $"\"{Path.Join(Path.Combine("videos", "output"), $"{fileName}_%03d.png")}\""
        ], command);
    }

    [Fact]
    public void Should_Prepend_Hwaccel_When_Set()
    {
        var settings = CreateSettings(s => s.HwAccel = "cuda");
        var dir = new TestDirectoryBridge(Path.Combine("videos", "a.mp4"));

        var command = Assert.Single(MainCommand.GenerateFfMpegCommand(settings, FileBridge.Default, dir));

        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")}\""
        ], command);
    }