简介:FFmpegAutoGenDemo.rar 是一套面向 C# 与 WPF 开发者的音视频处理示例工程,主要解决在桌面应用中调用 FFmpeg 原生接口时封装繁琐、调试门槛较高的问题。工程借助 FFmpeg.AutoGen 将 C 接口映射为 C# 类库,示例涉及音视频读取与解码、编码转码、滤镜应用、WPF 界面渲染、线程与异步处理、内存管理及错误日志等环节,适合具备一定 C# 基础、希望把多媒体能力集成进 WPF 项目的开发者参考学习。压缩包共 71 个文件,约 37.9MB,内有 19 个 dll、12 个 cs 源码、8 个 exe,以及 xaml、xml、config、resx 等工程配置文件,并附带解决方案、项目文件和 FFmpeg.AutoGen 依赖包,目录关系较清晰,便于直接打开研究调用流程。已有 1617 人学习下载。读者可获得可运行的 WPF 示例、FFmpeg 接口封装思路、音视频流处理代码骨架与排错线索,并据此改造成自己的播放、转码或滤镜实验工程。
1. 从一条手写命令说起:这个 Demo 替你省了哪几步
一个 C# + WPF 的 FFmpeg 自动生成 Demo,第一次跑起来时我盯着界面上那几行参数愣了几秒——平时在命令行里反复核对才敢回车的-c:v libx264 -crf 23 -preset slow,在这里变成了下拉框、滑杆和一行实时刷新的命令预览。FFmpegAutoGenDemo 干的就是这件事:把参数组装从脑子里搬到界面上,选好输入文件、挑个预设、拖一下 CRF,程序负责拼出完整参数组、拉起 ffmpeg 进程、再把 stderr 里的time=解析回进度条。名字里的 AutoGen 指的是命令行参数自动生成,不是 P/Invoke 绑定那一套,整个项目不依赖任何 FFmpeg 的 C# 封装库,只跟一个 exe 打交道。它适合两类人:刚被 FFmpeg 几十个参数劝退、想有个可视化参照物的开发者,以及已经会敲命令、但需要在 WPF 里包一层工具给非技术同事用的熟手。前者能照着界面反推每个参数的作用,后者能直接把它当骨架,换掉界面就能开工。
2. 工程骨架拆解:WPF 的 MVVM 分层与 FFmpeg 的进程边界
这个 Demo 最容易看走眼的地方,是它把「界面」和「FFmpeg」之间那条线划得很清楚。View 层只管绑定,ViewModel 管状态和命令,Service 层负责拼参数、起进程、读输出,Model 层放纯数据。没有把Process.Start塞进按钮的 Click 事件里,也没在 XAML 后台代码里拼字符串,这是它能被二次开发的前提——你要换界面换不掉业务,要换业务也碰不到 XAML。
2.1 目录结构与职责划分
按一个典型 WPF 项目的最小可用结构展开,实际的 Demo 大致长这样:
FFmpegAutoGenDemo/ ├── FFmpegAutoGenDemo.sln ├── src/ │ └── FFmpegAutoGenDemo/ │ ├── App.xaml / App.xaml.cs │ ├── Views/ │ │ └── MainWindow.xaml │ ├── ViewModels/ │ │ ├── MainViewModel.cs │ │ └── ObservableObject.cs # INotifyPropertyChanged 基类 │ ├── Models/ │ │ ├── TranscodeOptions.cs # 界面参数的对象化 │ │ └── MediaPreset.cs # 预设(快速/标准/高质量) │ ├── Services/ │ │ ├── FfmpegLocator.cs # 找 exe,找不到就报错 │ │ ├── FfmpegCommandBuilder.cs # 拼参数 │ │ ├── FfmpegRunner.cs # 起进程 + 读 stderr │ │ └── FfprobeService.cs # 取时长/分辨率 │ └── Converters/ │ └── BoolToVisibilityConverter.cs └── tools/ └── ffmpeg/ ├── ffmpeg.exe └── ffprobe.exeView 和 ViewModel 之间靠DataContext绑定,Service 由 ViewModel 持有实例,构造函数里注入或者直接 new 都行——Demo 规模下不必强上 DI 容器,多一层容器只会让新手第一次断点调试时找不到入口。tools/ffmpeg/这个目录是关键设计:可执行文件跟着仓库走,不要求用户自己配环境变量,FfmpegLocator优先从程序目录下的tools/ffmpeg找,找不到再回落到PATH。这一条能把「我这台机器能跑、同事机器上打不开」的问题砍掉一大半。
MediaPreset是容易被忽略但是决定手感的东西。它把fast / medium / slow、crf 18 / 23 / 28这类组合固化下来,界面上的下拉框绑定的是预设对象而不是裸字符串,用户选「高质量」时 ViewModel 一次性把一组参数写进TranscodeOptions。这样后续加一个「手机端小体积」预设,只需要在预设集合里加一条,界面不用动。
2.2 参数对象:先有模型,再有命令
所有界面输入先收敛成一个TranscodeOptions,这是整个项目的枢纽。命令构建器只认这个对象,不认识任何 WPF 控件,所以单元测试可以在没有 UI 线程的情况下跑。
namespace FFmpegAutoGenDemo.Models { public class TranscodeOptions { public string InputPath { get; set; } public string OutputPath { get; set; } // 视频侧:编码器 + 质量 public string VideoCodec { get; set; } = "libx264"; public int Crf { get; set; } = 23; // 0-51,越小质量越高 public string Preset { get; set; } = "medium"; // 音频侧 public string AudioCodec { get; set; } = "aac"; public int AudioBitrateKbps { get; set; } = 128; // 可选:等比缩放到指定宽度,高度用 -2 自动补齐偶数 public int? ScaleWidth { get; set; } public bool FastStart { get; set; } = true; // mp4 边下边播 public bool Overwrite { get; set; } = true; // 是否 -y 覆盖 } }几个属性的含义值得说清楚。Crf用int而不是double,因为 x264 的 CRF 在实际使用中整数档位就够,而且能避免后面数值转字符串时的区域设置问题。ScaleWidth用可空类型,为null表示不缩放,这个判断会在命令构建时决定要不要加-vf。Overwrite单独抽出来而不是硬编码-y,是因为在批量场景下误覆盖是真实会发生的数据事故,留一个开关比事后找恢复工具划算。
MediaPreset则是「一组TranscodeOptions的补丁」,选中预设时把这些值批量赋给TranscodeOptions实例,而不是替换整个对象——替换对象会丢掉用户已经填好的输入输出路径。
2.3 进程封装:为什么把 stderr 单独拎出来
FFmpeg 的行为很固定:进度、警告、错误全部往 stderr 写,stdout 一般是空的。所以FfmpegRunner必须重定向 stderr 并且异步读,否则管道缓冲区写满之后 ffmpeg 会阻塞在写日志上,进程假死,界面上的进度条就永远停在某个百分比。
using System; using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; namespace FFmpegAutoGenDemo.Services { public sealed class FfmpegRunner { // ffmpeg 的进度行:frame= 123 fps=25 ... time=00:00:05.12 ... private static readonly Regex TimeRegex = new Regex(@"time=(\d+):(\d+):(\d+\.?\d*)", RegexOptions.Compiled); private Process _process; public event Action<TimeSpan> ProgressChanged; public event Action<int> Exited; public void Start(string ffmpegPath, IReadOnlyList<string> args) { var psi = new ProcessStartInfo { FileName = ffmpegPath, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, StandardErrorEncoding = Encoding.UTF8 }; // 逐个参数塞进 ArgumentList,能自动处理空格和引号 foreach (var a in args) psi.ArgumentList.Add(a); _process = new Process { StartInfo = psi, EnableRaisingEvents = true }; _process.ErrorDataReceived += (s, e) => { if (string.IsNullOrEmpty(e.Data)) return; var m = TimeRegex.Match(e.Data); if (!m.Success) return; var ts = new TimeSpan( int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), (int)double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture)); ProgressChanged?.Invoke(ts); }; _process.Exited += (s, e) => Exited?.Invoke(_process.ExitCode); _process.Start(); _process.BeginErrorReadLine(); // 关键:异步读,不能同步 ReadToEnd _process.BeginOutputReadLine(); } public void Cancel() { if (_process == null || _process.HasExited) return; // ffmpeg 收到 q 或 Ctrl+C 才会写 moov,直接 Kill 容易留下损坏文件 _process.Kill(); } } }ArgumentList是这套封装里最值钱的一行。用Arguments拼字符串时,路径里带空格或者中文,你得自己负责加引号和转义,漏一处就是「Invalid argument」。ArgumentList由运行时逐个参数传递,引号、空格、等号都不用你操心,-i和后面的路径天然是两个参数。注意这个属性在 .NET Framework 4.x 上不存在,只有 .NET Core 3.0 以上和 .NET 5/6/7/8 才有,所以这个 Demo 的目标框架大概率是 .NET 6 或以上;如果你非要迁回 Framework,就得自己写一个QuoteArgument的辅助方法。
BeginErrorReadLine搭配ErrorDataReceived事件是非阻塞读的标准姿势,等价于在循环里ReadLineAsync。StandardErrorEncoding一定要显式指定,Windows 上默认取系统 ANSI 代码页,FFmpeg 输出的中文文件名会变成乱码,日志根本没法看。至于Exited事件里传ExitCode,是因为 ffmpeg 正常结束返回 0,出错返回非 0,界面可以据此决定是弹「完成」还是「失败」。
3. 命令自动生成核心:从界面参数到 ffmpeg 参数组的映射
拼参数这一步看起来最没技术含量,实际上全项目的坑一半在这里。手写命令时人是带着上下文的,知道自己在转什么;程序拼命令时没有上下文,多一个空格、少一个引号、数字用了逗号小数点,结果都是 ffmpeg 报一句看不懂的错然后退出。
3.1 FfmpegCommandBuilder:把选项拼成参数列表
构建器返回的是List<string>而不是一整条字符串,这是刻意为之。列表可以逐项断言,单元测试里Assert.Contains("-crf", args)比字符串Contains稳得多,也不会被路径里恰好包含-crf的目录名骗到。
using System.Collections.Generic; using System.Globalization; using FFmpegAutoGenDemo.Models; namespace FFmpegAutoGenDemo.Services { public class FfmpegCommandBuilder { public IReadOnlyList<string> Build(TranscodeOptions o) { var args = new List<string>(); args.Add("-hide_banner"); // 去掉版权头,日志更干净 args.Add(o.Overwrite ? "-y" : "-n"); // 覆盖策略必须显式给 args.Add("-i"); args.Add(o.InputPath); // 视频编码 args.Add("-c:v"); args.Add(o.VideoCodec); // CRF 和 preset 只对软编码器有意义 if (o.VideoCodec == "libx264" || o.VideoCodec == "libx265") { args.Add("-crf"); args.Add(o.Crf.ToString(CultureInfo.InvariantCulture)); args.Add("-preset"); args.Add(o.Preset); } // 缩放:宽固定,高按 -2 自动算偶数 if (o.ScaleWidth.HasValue && o.ScaleWidth.Value > 0) { args.Add("-vf"); args.Add($"scale={o.ScaleWidth.Value}:-2"); } // 音频编码 args.Add("-c:a"); args.Add(o.AudioCodec); args.Add("-b:a"); args.Add($"{o.AudioBitrateKbps}k"); // mp4 专属:把 moov 放到文件头,支持边下边播 if (o.FastStart) { args.Add("-movflags"); args.Add("+faststart"); } args.Add(o.OutputPath); return args; } // 给界面上那行只读文本框用,纯粹是可读性展示 public string ToDisplayString(IReadOnlyList<string> args) { return "ffmpeg " + string.Join(" ", args); } } }CultureInfo.InvariantCulture那一行是血泪经验。某些区域设置下23.ToString()会输出23,但23.5.ToString()会输出23,5,逗号传到 ffmpeg 里就是解析失败;CRF 是整数还看不出来,等到你把-ss起始时间、-t时长做成小数输入,这个问题立刻炸。规则很简单:任何要交给外部进程的数字,格式化时一律带InvariantCulture。
scale=宽:-2里的-2不是笔误。H.264 要求宽高都是偶数,-1会按比例算出奇数导致编码失败,-2则是「保持比例并取最近的偶数」,这个细节在切竖屏视频时特别容易翻车。
-movflags +faststart只对 mp4/mov 有效,接到 webm 上会报不认识该参数。合格的做法是把这一项和容器格式关联起来,容器从OutputPath的扩展名推导,扩展名不是 mp4/mov 就跳过。Demo 里如果没做这层判断,改输出格式时就会踩到。
3.2 预设与 MediaPreset:CRF、preset、码率的取舍
界面上那几个预设不是随便填的。preset决定的是编码速度和压缩率的平衡,从ultrafast到veryslow有九档,medium是默认值,往slow走同画质体积能再小 5%~10%,但编码时间可能翻倍;crf决定画质,18 接近视觉无损,23 是通用默认,28 适合网盘分享。两个参数是正交的,新手最容易把它们混为一谈,以为preset=slow就等于画质更好。
按这个逻辑配三档预设就够用了:
| 预设名 | VideoCodec | Crf | Preset | AudioBitrate | 适用场景 |
|---|---|---|---|---|---|
| 快速出片 | libx264 | 26 | ultrafast | 96k | 内部预览、临时素材 |
| 标准 | libx264 | 23 | medium | 128k | 通用分发 |
| 高质量 | libx264 | 18 | slow | 192k | 归档、二次剪辑素材 |
表里没有写-b:v,因为 CRF 模式下码率参数是无效的,两个一起给 ffmpeg 会忽略其中一个,容易让人误以为「我设了码率为什么体积没变」。如果业务场景确实需要固定码率(比如平台要求不超过某个值),那就得切到-b:v 2000k -maxrate 2500k -bufsize 5000k这一套,并且把 CRF 关掉,这是两种模式,不能混着用。
预设应用时建议做成「补丁式」:点预设只覆盖编码相关字段,不动InputPath、OutputPath、Overwrite。实现上就是预设对象持有一组可空字段,赋值时判空。
3.3 进度解析:从 stderr 的 time= 到进度条
ffmpeg 的进度行长这样:
frame= 145 fps= 48 q=28.0 size= 1024kB time=00:00:06.04 bitrate=1388.5kbits/s speed=2.01xtime=后面的值是已经处理到的媒体时间戳,用毫秒精度。它不等于文件时长,所以进度条必须有一个分母,分母来自ffprobe事先探测的总时长。没有分母就只能做「不确定进度」的动画条,用户体验差一截。
// ViewModel 里对 ProgressChanged 的处理 private void OnProgress(TimeSpan current) { if (_totalDuration <= TimeSpan.Zero) return; var percent = current.TotalSeconds / _totalDuration.TotalSeconds * 100.0; ProgressPercent = Math.Min(100.0, Math.Max(0.0, percent)); ElapsedText = $"{current:mm\\:ss} / {_totalDuration:mm\\:ss}"; }几个边界要说清楚。Math.Min/Max的钳位不是保险起见,是必须的:拼接、变速、滤镜这些操作会让time=略微超过总时长,不钳位进度条会跑到 100% 外面去。_totalDuration为零时直接返回,避免除零得到NaN,WPF 的ProgressBar拿到NaN会静默不渲染,看起来像卡死。格式化用mm\\:ss而不是mm:ss,TimeSpan的自定义格式里冒号是转义字符,不转义会抛FormatException,这个错误在调试时极其显眼但在代码评审时极容易被放过。
speed=2.01x这一项很多人忽略,其实它很有用:耗时预估 = 剩余时长 / speed。在长视频转码时把它显示出来,用户就不会因为「十分钟了才 30%」而反复点取消。
4. 跑起来:环境准备、编译与第一次转码闭环
从下载到看见第一帧转码完成,中间其实只有三件事:把 exe 放到程序能找到的地方、把工程编译出来、用一条已知正确的命令验证环境。这三步任何一步跳过,后面调试界面时你都分不清是代码问题还是环境问题。
4.1 把 ffmpeg.exe 放对位置:路径探测的三种写法
FFmpeg 的 Windows 预编译包在官网的下载页能拿到,解压后是bin/ffmpeg.exe、bin/ffprobe.exe、bin/ffplay.exe三个文件。别只拷ffmpeg.exe,ffprobe是取时长和分辨率的,缺了它进度条就没有分母。
放置位置按优先级来:
1. 程序目录/tools/ffmpeg/ffmpeg.exe ← 首选,随程序分发 2. 程序目录/ffmpeg.exe ← 便携式单文件分发 3. 系统 PATH 里的 ffmpeg.exe ← 开发机常见FfmpegLocator按这个顺序探测,全找不到时不要静默返回空字符串,直接抛一个带可读信息的异常,界面上弹一句「未找到 ffmpeg,请将 ffmpeg.exe 放到 tools/ffmpeg 目录」。含糊的NullReferenceException是新手放弃这个项目的头号原因。
public static string Resolve(string exeName) { var candidates = new[] { Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tools", "ffmpeg", exeName), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, exeName), }; foreach (var path in candidates) if (File.Exists(path)) return path; // 回落到 PATH var fromPath = Environment.GetEnvironmentVariable("PATH")? .Split(Path.PathSeparator) .Select(p => Path.Combine(p.Trim(), exeName)) .FirstOrDefault(File.Exists); return fromPath ?? throw new FileNotFoundException($"未找到 {exeName},请放到 tools/ffmpeg 目录下"); }AppDomain.CurrentDomain.BaseDirectory比Assembly.GetExecutingAssembly().Location更省事,后者在单文件发布或者影子复制场景下会指到奇怪的地方。调试时BaseDirectory指向bin/Debug/net6.0-windows/,所以tools/ffmpeg要么在 csproj 里配CopyToOutputDirectory,要么手动拷进去。配 csproj 更稳:
<ItemGroup> <None Include="..\..\tools\ffmpeg\ffmpeg.exe" CopyToOutputDirectory="PreserveNewest" Link="tools\ffmpeg\ffmpeg.exe" /> <None Include="..\..\tools\ffmpeg\ffprobe.exe" CopyToOutputDirectory="PreserveNewest" Link="tools\ffmpeg\ffprobe.exe" /> </ItemGroup>4.2 编译与首次运行
环境要求就两条:装了 .NET SDK(版本跟 csproj 里的TargetFramework对齐),以及 Windows(WPF 只能在 Windows 上跑,别在 Linux 上折腾)。
# 还原 + 编译 dotnet build src/FFmpegAutoGenDemo/FFmpegAutoGenDemo.csproj -c Debug # 直接运行,调试时用这个而不是双击 exe,能看到控制台异常 dotnet run --project src/FFmpegAutoGenDemo/FFmpegAutoGenDemo.csproj用dotnet run而不是双击bin/Debug下的 exe,原因是启动阶段抛出的异常(比如找不到 ffmpeg、XAML 解析失败)在双击模式下会直接崩掉不留痕迹,一位同事排查了半小时才发现是自己把ffmpeg.exe放到了Debug而不是Debug/net6.0-windows。用命令行启动至少能在终端里看到堆栈。
编译前先确认TargetFramework。如果是net6.0-windows而你机器上只有 .NET Framework 4.8,dotnet build会报 SDK 不支持;反过来项目是net48而你用ArgumentList,会报「ProcessStartInfo 不包含 ArgumentList 的定义」。这两个错都很直白,但第一次遇到容易懵。
4.3 用一个 30 秒样本走通最小闭环
在信任界面之前,先用命令行跑通一次,确认 exe 本身没问题:
# 生成一个 30 秒的测试视频,避免拿真实素材试错 ffmpeg -hide_banner -y -f lavfi -i testsrc=size=1280x720:rate=30 -t 30 -c:v libx264 -crf 23 sample.mp4 # 手动跑一次和 Demo 等价的命令,看它是否成功 ffmpeg -hide_banner -y -i sample.mp4 -c:v libx264 -crf 26 -preset ultrafast -c:a aac -b:a 96k out.mp4testsrc是 FFmpeg 内置的测试图源,不带任何素材版权问题,也方便复现。第二条命令吐出的参数顺序应该和FfmpegCommandBuilder.Build的产物一致——参数顺序在 ffmpeg 里是有语义的,-i之前的参数作用于输入,之后的参数作用于输出,把-ss放到-i前面是快速定位(关键帧级),放到后面是精确定位(解码级,慢但准)。构建器里所有输出侧参数都排在-i之后,这个约定不能破。
命令行跑通之后,界面上的流程就是:选输入 → 选输出 → 选预设 → 点开始。点之前先瞄一眼界面上那行只读的命令预览,把它和刚才手敲的命令对一下,一致就基本没跑了。
4.4 ffprobe 取时长,进度才有分母
FfprobeService只做一件事,但它是进度条能否工作的前提:
public async Task<double> GetDurationSecondsAsync(string input) { var psi = new ProcessStartInfo { FileName = FfmpegLocator.Resolve("ffprobe.exe"), UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; // 只输出纯数字,方便直接 double.Parse psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("error"); psi.ArgumentList.Add("-show_entries"); psi.ArgumentList.Add("format=duration"); psi.ArgumentList.Add("-of"); psi.ArgumentList.Add("default=noprint_wrappers=1:nokey=1"); psi.ArgumentList.Add(input); using var p = Process.Start(psi); var text = await p.StandardOutput.ReadToEndAsync(); await p.WaitForExitAsync(); return double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0; }-of default=noprint_wrappers=1:nokey=1这两个开关的作用是剥掉键名和分段包装,让输出只剩30.033333这样一行数字。不加的话输出是duration=30.033333,你还得多写一步Split('=')。NumberStyles.Float加InvariantCulture同样是防小数点变逗号。
format=duration对绝大多数容器有效,但对某些直播流或者损坏文件会返回N/A,此时TryParse失败返回 0,进度条就退化成不确定状态。这个降级路径要写,不然后面接手的人会以为是解析写错了。
5. 避坑与排查:黑框一闪、中文路径与进度卡死的五笔血泪账
这个项目功能不复杂,坑却相当集中,而且大多不是 WPF 的问题,是「跟外部进程打交道」这一类活儿共通的毛病。下面五条是我在一次内部工具改造里真实按顺序踩过的,按出现频率排。
5.1 现象:双击 exe 窗口一闪就没了,连报错都看不到
原因基本是启动阶段抛了未捕获异常:找不到ffmpeg.exe、XAML 里绑定了一个不存在的属性、或者TargetFramework需要的运行时没装。WPF 在双击模式下没有控制台窗口,AppDomain.UnhandledException又没挂处理器,异常直接吞掉进程就退了。
解决分两步。开发期一律用dotnet run启动,堆栈直接打在终端里。发布版在App.xaml.cs里挂全局兜底,把异常写到同目录的日志文件再弹个 MessageBox:
protected override void OnStartup(StartupEventArgs e) { DispatcherUnhandledException += (s, args) => { File.AppendAllText("startup-error.log", args.Exception.ToString()); MessageBox.Show(args.Exception.Message, "启动失败"); args.Handled = true; Shutdown(1); }; base.OnStartup(e); }5.2 现象:路径里带中文或者空格,ffmpeg 报Invalid argument或直接提示文件不存在
原因是参数用字符串拼接传给ProcessStartInfo.Arguments,"C:\我的视频\a b.mp4"被空格切成两段,ffmpeg 把后半段当成另一个参数。中文额外还有一层编码问题,StandardErrorEncoding没设成 UTF-8 时,日志里的中文全是问号,导致你根本看不出是路径错了还是参数错了。
解决:.NET 6 以上直接用ArgumentList逐个添加,让运行时处理引号。必须留在 .NET Framework 上的话,自己写转义——包含空格或引号的参数整体包一层双引号,内部的双引号转成\",末尾的反斜杠要双写,否则会把收尾引号转义掉。这条规则记下来,比背任何库都管用。
5.3 现象:进度条停在 12% 不动,但任务管理器里 ffmpeg 还在吃 CPU
这是典型的管道死锁。RedirectStandardError = true却用同步的ReadToEnd()在WaitForExit()之后读,或者压根没读,stderr 的缓冲区(默认几 KB)写满之后 ffmpeg 就阻塞在写日志的调用上,进程活着但不干活。
解决:进程Start()之后立刻BeginErrorReadLine(),用事件消费;或者在WaitForExitAsync()之前起一个ReadLineAsync循环把 stderr 抽干。判断依据很简单——如果 ffmpeg 的 CPU 占用掉到 0 而进度不动,八成就是它。这个坑在没有进度输出、日志量大的场景下更容易触发,因为它刷得快。
5.4 现象:本机能跑,拷到同事电脑上提示找不到 ffmpeg
原因是依赖了PATH环境变量,而同事机器上装的是另一个软件自带的 ffmpeg,版本还不一样,行为差异导致命令参数不认。更隐蔽的一种是 32/64 位不匹配,x86 进程调 64 位 exe 在某些环境下会失败。
解决:把ffmpeg.exe和ffprobe.exe一起打进发布目录,FfmpegLocator优先找程序目录,找不到再回落PATH,并且启动时把探测到的完整路径和ffmpeg -version的第一行打到日志里。以后任何「在我这好好的」的争论,先让对方把这一行发过来。
5.5 现象:转码到 99% 报Permission denied,或者源文件莫名被改成 0 字节
两种可能。一种是输出文件正被别的程序打开——最常见的是你在 Demo 里用MediaElement预览源视频,播放器持有文件句柄,ffmpeg 想写同名输出就失败。另一种是输入输出路径写成同一个,-y一开,ffmpeg 先把目标文件截断,再想读源文件已经空了。
解决:输出路径强制和输入路径做字符串比较,相同就自动追加_out后缀;预览用的MediaElement在开始转码前Source = null并调用Close()。批量场景再稳一点的做法是先输出到xxx.tmp.mp4,成功后File.Move覆盖目标,失败就删临时文件,中途取消也不会留下半截损坏视频。
6. 二次开发:把 Demo 改成你自己的批量转码工具
这个 Demo 的价值不在「能转一个视频」,在于它把「界面参数 → 参数列表 → 进程 → 进度」这条链路跑通了,你换任何一环都不用重写其他部分。真要往生产工具改,通常从三个方向下手:加批量队列、支持硬件编码、把事件回调换成IProgress<T>以便和 async/await 混用。
先看改造落点,别一上来就动 XAML:
| 想加的能力 | 落点文件 | 注意点 |
|---|---|---|
| 批量队列 | MainViewModel+ 任务集合 | 并发数别超过物理核数,机械硬盘上并发写反而更慢 |
| 自定义参数透传 | FfmpegCommandBuilder.Build尾部 | 追加到输出路径之前,不要让用户能改-i |
| 硬件编码 | MediaPreset+Build里的编码器分支 | h264_nvenc不吃-crf,对应的是-cq |
| 多后端/可测试 | 抽IFfmpegRunner接口 | 事件模型换成IProgress<double> |
硬件编码这条特别值得单独说,因为它是「看起来只是换个编码器名」但实际上参数体系完全不同的典型。libx264用-crf 23 -preset medium,h264_nvenc用的是-rc vbr -cq 23 -preset p5,h264_qsv又是-global_quality 23。构建器里如果只判断VideoCodec == "libx264"就加 CRF,切到 NVENC 时这些参数会被忽略或者报错,输出质量完全不受控。合格的写法是把「编码器 → 质量参数组装」抽成策略:
private void AppendQualityArgs(List<string> args, TranscodeOptions o) { switch (o.VideoCodec) { case "libx264": case "libx265": args.Add("-crf"); args.Add(o.Crf.ToString(CultureInfo.InvariantCulture)); args.Add("-preset"); args.Add(o.Preset); // medium / slow / ... break; case "h264_nvenc": case "hevc_nvenc": args.Add("-rc"); args.Add("vbr"); args.Add("-cq"); args.Add(o.Crf.ToString(CultureInfo.InvariantCulture)); args.Add("-preset"); args.Add("p5"); // NVENC 的 preset 是 p1-p7 break; default: // 未知编码器不加质量参数,让 ffmpeg 用自己的默认值 break; } }顺手把事件模型换掉,是另一个性价比很高的改造。FfmpegRunner现在用event Action<TimeSpan>,在 WPF 里够用,但一旦你要在控制台版、服务端、单元测试里复用,事件就得手动解绑,容易漏。换成IProgress<T>之后,WPF 侧传new Progress<TimeSpan>(OnProgress)会自动回到 UI 线程,测试侧传一个记录值的假实现就行:
public async Task<int> RunAsync( string ffmpegPath, IReadOnlyList<string> args, IProgress<TimeSpan> progress, CancellationToken ct) { var psi = new ProcessStartInfo(ffmpegPath) { /* 同上,省略 */ }; foreach (var a in args) psi.ArgumentList.Add(a); using var p = Process.Start(psi); using var reg = ct.Register(() => { try { p.Kill(); } catch { } }); var buffer = new StringBuilder(); string line; while ((line = await p.StandardError.ReadLineAsync()) != null) { buffer.AppendLine(line); var m = TimeRegex.Match(line); if (m.Success) { progress?.Report(new TimeSpan( int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), (int)double.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture))); } } await p.WaitForExitAsync(ct); return p.ExitCode; }注意ct.Register里调Kill()之后 ffmpeg 是被硬杀的,输出文件大概率不可播。真要做取消功能,正确做法是往进程的 stdin 写一个q(需要RedirectStandardInput = true),让 ffmpeg 自己走完收尾流程写出 moov;硬杀只作为超时兜底。这个差别在长视频上非常明显,我见过一个工具因为直接Kill,用户取消一次就多出一个 2GB 的坏文件。
最后提一个验证方法,比看界面靠谱:改完构建器之后,写一个只测Build的测试,把同一组TranscodeOptions喂进去,断言参数列表里-i的下标一定小于输出路径的下标、-crf只在软编码时出现、ScaleWidth为空时不存在-vf。这几条断言能挡住九成的参数顺序和条件分支错误,而且跑起来只要毫秒级,不用等一个视频转完。
从那以后我每次接 FFmpeg 相关的活,都先在命令行把目标命令完整跑通一遍、把输出贴进代码,再动手写构建器——顺序反过来,调试时间至少翻三倍。希望帮到你。
本文还有配套的精品资源,点击获取