Loading src/VideoSlice.Cli/MainCommand.cs +26 −13 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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(); } } Loading @@ -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; } Loading src/VideoSlice.Cli/Program.cs +5 −1 Original line number Diff line number Diff line Loading @@ -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 src/VideoSlice.Cli/VideoSlice.Cli.csproj +3 −0 Original line number Diff line number Diff line Loading @@ -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> Loading src/VideoSlice.Cli/VideoSlice.cs 0 → 100644 +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 src/VideoSlice.Tests/MainCommandTest.cs +55 −6 Original line number Diff line number Diff line Loading @@ -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)); } Loading @@ -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); } Loading Loading
src/VideoSlice.Cli/MainCommand.cs +26 −13 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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(); } } Loading @@ -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; } Loading
src/VideoSlice.Cli/Program.cs +5 −1 Original line number Diff line number Diff line Loading @@ -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
src/VideoSlice.Cli/VideoSlice.Cli.csproj +3 −0 Original line number Diff line number Diff line Loading @@ -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> Loading
src/VideoSlice.Cli/VideoSlice.cs 0 → 100644 +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
src/VideoSlice.Tests/MainCommandTest.cs +55 −6 Original line number Diff line number Diff line Loading @@ -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)); } Loading @@ -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); } Loading