资讯动态

C#调用FFmpeg实现视频水印与分辨率处理实战指南

发布时间:2026/10/2 21:47:24 来源:尧图企业网站定制
做C#上位机的人迟早都会碰上音视频需求。我最近的项目就有一项录像文件要加水印水印位置还要能调分辨率按16:9出。需求就一句话落地的时候才发现一堆问题——FFmpeg命令参数多到能绕晕人网上教程又大多是Linux环境下纯命令行操作换到C# Windows进程里一拼接各种转义、路径、滤镜语法的坑全冒出来了。这篇文章把我整理过的完整方案记录下来从C#里用Process调用FFmpeg的骨架讲起到drawtext和overlay水印参数的坐标计算再到录音录像时设置分辨率的宽高比处理逻辑最后是几个真正跑过才知道的坑。适合正在做C#音视频采集、录像存储、批处理加水印这类功能的开发者参考也欢迎比我更有经验的兄弟多指正。1. 先说结论C#项目里调用FFmpeg绕不开裸命令拼接1.1 为什么最终没选NuGet封装库刚开始我也想过直接引一个NuGet封装库像FFMpegCore或Xabe.FFmpeg调用起来像写普通C#方法一样感觉省事。但用下来发现封装库适合的是转个格式、抽个帧、剪一段这种标准场景。一旦涉及水印位置表达式、多路输入、摄像头采集设备参数这种高度定制的内容封装层的参数传递往往非常别扭有些库甚至不支持完整的滤镜语法最后还是要暴露原始命令字符串。而且封装库还有个隐性问题出了问题很难排查。它把stderr日志包了一层你不容易直接看到FFmpeg到底报了什么错。我后来干脆放弃封装直接在程序里内置ffmpeg.exe自己拼参数、自己拉起进程、自己读日志。自由度最高出了问题也能把命令原样复制出来在命令行里复现。1.2 Process调用骨架先把日志从stderr接住C#调FFmpeg的核心就是ProcessStartInfo。有一个细节很多新手会忽略FFmpeg的正常进度和错误日志都输出在标准错误stderr不是标准输出。所以必须把RedirectStandardError设为true否则你只能拿到一个退出码出错原因全靠猜。public static int RunFFmpeg(string arguments) { var psi new ProcessStartInfo { FileName D:\ffmpeg\bin\ffmpeg.exe, Arguments arguments, UseShellExecute false, CreateNoWindow true, RedirectStandardError true, RedirectStandardOutput true }; using var proc Process.Start(psi); string log proc.StandardError.ReadToEnd(); proc.WaitForExit(); if (proc.ExitCode ! 0) throw new InvalidOperationException($FFmpeg执行失败退出码{proc.ExitCode}\n参数{arguments}\n日志{log}); return proc.ExitCode; }这段代码我用到现在结构一直没变过。有两个小细节值得提-y参数不能少否则第二次运行遇到同名文件会卡在交互式确认提示上进程永远不退出CreateNoWindow要设为true不然录屏或自动运行场景下会突然弹出黑窗口。这个骨架属于最基础的一层后面所有功能都是往arguments这个字符串里填东西。2. 水印参数拆解drawtext和overlay里的坐标变量与转义规则2.1 文字水印和图片水印用在不同场合FFmpeg加水印有两条路线文字类的水印用drawtext滤镜图片Logo类的水印用overlay滤镜。drawtext适合动态内容比如时间戳、通道名、机位编号。overlay适合静态Logo比如PNG格式的台标、公司LOGO带透明通道的效果最好。基本用法是这样# 文字水印 ffmpeg -i input.mp4 -vf drawtextfontfilesimhei.ttf:textCAM-01:x10:y10:fontsize24:fontcolorwhite output.mp4 # 图片水印 ffmpeg -i input.mp4 -i logo.png -filter_complex overlay10:10 output.mp4overlay需要两个输入所以走-filter_complex而不是-vf这是新人最容易搞混的地方。我一开始也是把overlay塞进-vf里FFmpeg直接报错说不认识这个滤镜。2.2 水印位置的坐标变量表水印位置的坐标表达式不难但两套滤镜的变量名不一样特别容易记混。drawtext里w和h是主视频画面的宽高tw和th是文字渲染出来的宽高。overlay里main_w和main_h是主视频的宽高overlay_w和overlay_h是图片的宽高。坐标原点在画面左上角x向右为正y向下为正。所以要放右上角时不能写死一个像素值因为视频尺寸会变最稳的写法是用表达式计算出来。水印位置drawtext文字水印overlay图片水印左上角x20:y20overlay20:20右上角xw-tw-20:y20overlaymain_w-overlay_w-20:20左下角x20:yh-th-20overlay20:main_h-overlay_h-20右下角xw-tw-20:yh-th-20overlaymain_w-overlay_w-20:main_h-overlay_h-20正中央x(w-tw)/2:y(h-th)/2overlay(main_w-overlay_w)/2:(main_h-overlay_h)/2这个表里的表达式可以直接抄。那个减20是边距代表水印离画面边缘20像素你按需调整。我实际项目里最常用的需求是右上角留时间戳、右下角留通道名。极少有人需求居中水印但接口层面我会把居中逻辑也留出来。2.3 时间戳水印动态文字与转义录像文件常需要叠加时间戳。有人会想在FFmpeg里用内置表达式%{localtime}但这里有个坑%{localtime}的格式串里冒号必须写成\:整个表达式又要放进滤镜参数里转义层数多了之后很难读drawtextfontfilesimhei.ttf:text%{localtime\:%Y-%m-%d %H\:%M\:%S}:x10:y10我后来直接在C#侧把时间格式化成字符串再塞进text参数里。省去一层动态表达式逻辑更直观出问题也容易定位。string timestamp DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss);注意这个时间字符串里的冒号在传递给drawtext时也要做转义否则FFmpeg会把text2024-05-20 14:30:00里的冒号误认为滤镜参数分隔符。虽然单引号包裹的情况下多数版本能扛住但不同平台行为不一致保险做法是统一把:替换成\:。2.4 多行多列水印的滤镜拼接多行水印有三种做法。最简单的写多个drawtext滤镜用逗号拼在同一组-vf里每个滤镜指定不同位置和文字。-vf drawtextfontfilesimhei.ttf:textLine1:x10:y10:fontsize24,drawtextfontfilesimhei.ttf:textLine2:x10:y50:fontsize18如果行数很多可以用textfile参数让FFmpeg从外部文本文件读多行内容文件里用换行分隔每行渲染一行-vf drawtextfontfilesimhei.ttf:textfileC\:/watermark/notice.txt:x10:y10:fontsize20textfile的优点是文本内容不影响命令行长度也不用手动转义大量特殊字符。缺点是文本文件路径里的盘符冒号还是得转义。多列水印就比较棘手了本质上是多个drawtext滤镜在水平方向分别排布没有捷径只能是每个滤镜写清楚自己的x坐标。2.5 C#端封装水印表达式既然每次都要拼滤镜表达式我习惯把它封装成一个方法把这些转义逻辑收拢在一个地方public enum WatermarkPosition { TopLeft, TopRight, BottomLeft, BottomRight, Center } public static string BuildDrawText(string text, int fontSize, WatermarkPosition position) { string fontPath C:\Windows\Fonts\msyh.ttc.Replace(:, \:); string safeText text.Replace(:, \:); string xy position switch { WatermarkPosition.TopLeft x20:y20, WatermarkPosition.TopRight xw-tw-20:y20, WatermarkPosition.BottomLeft x20:yh-th-20, WatermarkPosition.BottomRight xw-tw-20:yh-th-20, WatermarkPosition.Center x(w-tw)/2:y(h-th)/2, _ x20:y20 }; return $drawtextfontfile{fontPath}:text{safeText}:fontsize{fontSize}:fontcolorwhite0.8:{xy}; }这样调用生成字符串后直接拼到-vf后面就行。封装的好处是后面不管加多少路通道水印的位置、字体、颜色都在一个地方改。3. 分辨率设置背后的宽高比逻辑4:3、16:9、5:4怎么处理才不变形3.1 宽高比到底怎么算标题里说的分辨率一般是宽高比4:3和16:9少数5:4这句话其实点出了一个关键问题需求方给分辨率本质上给的是宽高比档位。你要先能分辨出哪些标准分辨率是哪种比例。4:3早期电视和监控行业用得最多常见的有640x480、800x600、1024x768、1280x960、1600x1200。16:9现在几乎所有视频平台和显示器的默认档位常见的有1280x720、1920x1080、2560x1440、3840x2160。5:4相对小众最常见的是1280x1024以前某些LCD显示器和部分监控设备使用。宽高比常见分辨率4:3640x480、800x600、1024x768、1280x960、1600x120016:91280x720、1366x768、1920x1080、2560x1440、3840x21605:41280x1024、2560x2048判断方法很简单宽除以高。1280/960等于1.333就是4:31920/1080等于1.778就是16:91280/1024等于1.25是5:4。头几次容易把1280x1024误当成4:3算一下就清楚了。3.2 直接-s拉伸为什么会变形FFmpeg里设置输出分辨率最直接的办法是-s 1280x720。但如果源视频是1280x9604:3需求要1920x108016:9直接-s 1920x1080的话画面会被横向拉伸人脸会变胖。这个问题在录像场景里几乎是必踩的。原理上要理解显示宽高比和像素宽高比的区别。一个视频文件里存储的是像素矩阵比如源是1280x960播放器按什么比例展示取决于容器里的SARSample Aspect Ratio像素宽高比和DARDisplay Aspect Ratio显示宽高比。你用-s强行把存储尺寸改了但没有同步调整SAR和DAR的对应关系播放器就会把每一帧像素都拉变形。更推荐的做法是用scale滤镜替代-s因为scale配合force_original_aspect_ratio选项可以让FFmpeg在改分辨率时自动维护比例关系。3.3 三种处理策略拉伸、裁剪、加边实际做项目时会遇到三种场景对应三种处理方案场景一允许拉伸变形。这种要求一般出现在随便录一下能用就行的场合。直接-s或者scale1920:1080快但丑不推荐。场景二保持比例但可以裁剪画面。适合监控画面主体在中间裁掉两边不影响关键信息。-vf scale1920:1080:force_original_aspect_ratioincrease,crop1920:1080先把画面等比放大到能完全覆盖1920x1080此时尺寸会大于目标然后再居中裁剪到目标。注意scale滤镜里输出宽高目标写的是1920:1080不是1920x1080。场景三保持比例且保留全部画面用黑边补齐。适合会议录像、教学录制这类不能裁内容的场景。-vf scale1280:720:force_original_aspect_ratiodecrease,pad1280:720:(ow-iw)/2:(oh-ih)/2:black先把画面等比缩小到能完整放进1280x720此时尺寸会小于目标再用pad把周边补成黑边。(ow-iw)/2和(oh-ih)/2是把画面居中的公式如果你想把内容往下压一点留出标题位置可以调整y坐标。我自己的经验能调整采集端的分辨率就别动滤镜。Windows摄像头采集如果支持直接在采集命令里用-video_size 1280x720拿到原生16:9画面后端滤镜就少一层处理性能和画质都更好。3.4 编码器对分辨率宽高的约束还有一个细节容易忽略H.264编码器要求宽高都是偶数。如果你设置了437x312这种非偶数分辨率编码器要么报错要么自动补成438x312结果和预期不符。所以涉及scale时习惯性给宽高套一层偶数对齐-vf scaletrunc(iw/2)*2:trunc(ih/2)*2trunc(iw/2)*2表示先除以2取整再乘2得到不大于原始宽度的最大偶数。这套写法和常见的-2写法效果等价。做多路适配的时候先在C#侧把目标宽高算成偶数再传给FFmpeg能省掉很多莫名其妙的报错。4. 录音录像场景的命令拼装采集、编码、C#进程调用细节4.1 Windows下指定摄像头和麦克风标标题里有录音录像在Windows上走的是dshow框架。先要知道设备名称可以在命令行里枚举ffmpeg -list_devices true -f dshow -i dummy输出里有个DirectShow video devices段和DirectShow audio devices段设备名就写在引号里。设备名经常有空格比如USB Camera在命令行里要用引号包起来。在C#拼字符串时这个引号需要小心处理string cameraName USB Camera; string inputVideo -f dshow -video_size 1280x720 -framerate 25 -i \ cameraName \;注意这里-video_size 1280x720是设置采集分辨率输出分辨率是后面另一个环节的事。摄像头如果不支持这个采集分辨率FFmpeg会静默回退到设备默认值所以正式项目里最好提前用ffprobe或枚举设备能力确认。4.2 一条完整录像命令的参数顺序ffmpeg命令的参数顺序有讲究全局参数-y在最前输入参数跟随-i输出参数放在输出文件名之前。录像加水印的完整命令大概长这样ffmpeg -y \ -f dshow -video_size 1280x720 -framerate 25 -i Camera Name \ -f dshow -i Microphone Name \ -t 00:30:00 \ -vf drawtextfontfileC\\:/Windows/Fonts/msyh.ttc:text2024-05-20 14:30:00:xw-tw-20:y20:fontsize24:fontcolorwhite0.8 \ -c:v libx264 -preset veryfast -crf 23 \ -c:a aac -b:a 128k \ output.mp4参数含义拆开看-t限制录制时长-vf在编码前处理视频帧水印、scale都放这里-c:v libx264软件编码-crf 23是画质档位数值越小画质越好文件越大-c:a aac是音频编码。注意-vf和-s不要同时用否则-s作为输出参数会覆盖滤镜链的输出尺寸导致滤镜处理的画面比例和最终输出对不上。4.3 C#里的转义地狱在C#里拼这条命令最痛的是转义。我画个层级C#字符串 → Windows命令行 → FFmpeg参数解析 → 滤镜内部解析每一层都在处理引号、反斜杠、冒号。以fontfileC:/Windows/Fonts/msyh.ttc为例drawtext要求盘符冒号转义所以在滤镜里要写成fontfileC\:/Windows/Fonts/msyh.ttc。这个\在C#普通字符串里要写成\\如果用逐字字符串...就只写一个。我在代码里用Replace(:, \:)就是为了让这段逻辑可读。水印文本里的冒号同理。时间戳2024-05-20 14:30:00在滤镜里安全的写法是text2024-05-20 14\\:30\\:00。文本中如果还有单引号更麻烦要替换成\。遇到这种复杂文本我建议一律走textfile参数把内容写到外部文本文件避免文本内容参与多层转义。4.4 可直接抄走的C#录像调用封装把所有参数拼装逻辑封装成一个方法是我最终维护时会比较舒服的形态public static int StartRecording(string cameraName, string micName, string outputPath, string watermarkText, int durationSeconds, int width, int height) { string watermark BuildDrawText(watermarkText, 24, WatermarkPosition.TopRight); var args new Liststring { -y, -f, dshow, -video_size, ${width}x{height}, -framerate, 25, -i, Quote(cameraName), -f, dshow, -i, Quote(micName), -t, durationSeconds.ToString(), -vf, Quote(watermark), -c:v, libx264, -preset, veryfast, -crf, 23, -c:a, aac, -b:a, 128k, Quote(outputPath) }; return RunFFmpeg(string.Join( , args)); } private static string Quote(string text) { return \ text.Replace(\, \\\) \; }注意Quote方法水印表达式里含空格或特殊字符时必须整体加引号设备名和输出路径含空格也一样。Windows命令行解析对引号的处理比较特殊这套写法实测下来稳定性最好。也提醒一句不要在.NET里用ArgumentList传含空格的FFmpeg参数某些版本在Windows上会自动加引号和FFmpeg滤镜里的引号叠在一起会出现诡异的解析问题我踩过一次之后回到字符串拼接了。5. 中文乱码、滤镜报错与多路并发上线之后踩过的坑5.1 中文水印乱码与字体路径drawtext默认字体在Windows的FFmpeg包里经常没配置表现就是中文全部变成方块。解决方式很简单显式指定中文字体。string fontPath C:\Windows\Fonts\msyh.ttc; // 微软雅黑如果你用simhei.ttf黑体也可以。但这里有个隐藏问题drawtext加载.ttc字体集合时如果默认face字形不合适可能显示歪斜建议直接用.ttf。另外字体路径必须在每台部署机器上存在正式环境装完系统后要检查字体文件在不在否则用户机器上录出乱码水印你又得远程排查。fontfile的盘符冒号要转义写成C\:这个在代码里用Replace(:, \:)搞定。中文不要用arial.ttf那是拉丁字体没有中文字形。水印文本建议用UTF-8编码写入textfileFFmpeg默认按UTF-8解析编码不对会显示乱码。5.2 滤镜语法报错的排查套路滤镜报错是FFmpeg使用中最高频的问题常见错误有三类No such filter: xxx当前FFmpeg版本没编译这个滤镜。用ffmpeg -filters查一下或者换完整版FFmpeg包。Option not found滤镜名对但参数写错了。比如drawtext里写fontsize没错写成size就报这个。Invalid argument或Failed to inject frame多半是参数值里的冒号、引号转义不对把滤镜表达式截断了。排查方法其实很简单把C#拼好的arguments字符串原样打印出来拿到命令行窗口里手动跑一遍看FFmpeg输出的详细错误。它比进程方式信息全得多因为这时的报错不会因为日志读取逻辑被截断。我已经养成了习惯RunFFmpeg方法里的异常信息一定带上完整arguments否则生产环境出了错你连现场都复现不了。再提醒一个点滤镜表达式中间不要留多余空格但整个滤镜串前后要加引号。空格留在表达式里会被当成参数分隔导致Option not found。5.3 多路并发与编码器选择上位机场景经常要同时录多路摄像头每路起一个FFmpeg进程。纯软编libx264在同时处理四路以上的720p时会明显吃力体现在CPU占用飙升、丢帧、时间戳错乱。这时候建议切硬件编码硬件平台编码器名称NVIDIA显卡h264_nvencIntel核显h264_qsvAMD显卡h264_amf我实测用h264_qsv在普通i5平台上同时录8路720p还能扛得住。但要注意硬件编码对滤镜的支持和参数兼容性各有不同。比如某些显卡驱动对不常见的分辨率支持不好报Failure at nvenc init这时把分辨率调成标准档位就能解决。还有一点drawtext是逐帧绘制这个阶段在CPU上跑所以即使编码切了硬编快速变化的动态水印还是有CPU开销。实测中一次绘制多个大字号水印CPU占用会明显上涨。如果只是时间戳可以用页面上的帧信息叠加但没有现成方案只能提前算好性能预算。5.4 录出来的文件时长不对或打不开这类问题在我接手的项目里反复出现。第一种录制中途进程被杀或系统断电MP4文件没有正常写入moov元数据导致文件打不开或者播放器不显示时长。轻量修复命令ffmpeg -err_detect ignore_err -i broken.mp4 -c copy repaired.mp4第二种设置-t 3600但实际文件时长比预期长或短。原因多半是-t写在了-i前面。-t放在输入前是限制读取输入的时间放在输出文件名前才是限制输出时长。这个坑我栽过一次查了半天才发现是位置问题。第三种录像文件越来越大直到磁盘写满。除了检查磁盘更工程化的做法是把长时间录制切成多个分段用segment切片-f segment -segment_time 3600 -reset_timestamps 1 output_%04d.mp4每小时一个文件配合定时清理机制比单文件录十几个小时稳得多。这段命令同样可以嵌入C#参数列表里不改变原有调用逻辑。写在后面水印、分辨率和录像录制这三件事单拆开都不难但组合到一起就牵扯出坐标表达式、宽高比换算、多层转义、编码器兼容这些细碎问题。我之所以把这套方案整理成文是因为每次新接一个音视频相关的C#项目都要重查一遍这些参数不如沉淀一份能直接复用的代码骨架。如果你也在做类似功能建议先拿命令行把滤镜表达式测通再拆进C#封装的参数模型里顺序反了排错体验会很折磨。最后再说一句字体路径、设备名、输出路径这三个地方是转义问题的高发区写代码时多看一眼能帮你省下不少半夜排查的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑