☰
C#桌面视频加密转码工具:FFmpeg、AES分块加密与VLC预览源码拆解
2026/10/24 4:18:32 网站建设 项目流程

简介:这是一套面向具备一定 C# 基础、关注音视频安全与桌面应用开发的读者编写的视频加密程序源代码,可用于研究本地视频防破解、加密存储与受控播放的完整实现思路。项目采用 AES 算法对视频文件进行完全加密,播放器选用开源 VLC 直接读取解密后的字节流,并借助内置微型 Web 服务器实现边解密边播放,减少明文文件落盘与暴露风险;转码环节由 FFmpeg 完成,代码包内已附带相应工具,便于串起加密、转码、播放的完整链路。压缩包共 591 个文件、约 64.32MB,包含 34 个 cs 源码、381 个 dll 依赖、98 个 mo 语言资源,以及 exe、config、nupkg、png 等运行配置与界面素材,可直接编译调试并据此做二次开发。目前已有 2530 人学习,适合用作课程设计、桌面项目原型或音视频加密方案的参考资料,也便于在现有代码上继续扩展权限控制与播放日志等模块。

1. 一个把「转码」和「加密」拧在一起的桌面工具

第一次拿到这份视频文件加密程序源代码,我以为是玩具——无非把 mp4 读进内存、跑一遍 AES、再写出去。真正跑起来才明白,它把手头最难凑到一起的三件事做完了:C# 写的精美 UI 负责交互和任务编排,FFmpeg 负责视频转码,VLC 负责加密文件的即时预览。转码和加密是两条完全不同的错误处理链,一条怕编码参数翻车、一条怕密钥和文件头对不上,能塞进同一个工程还不互相拖累,说明作者被真实使用场景教育过。

它适合三类人:想学 C# 桌面端完整工程分层的人,需要给视频做本地加密存档的人,以及折腾过 FFmpeg 却始终搞不定「进度条不抽搐」的人。后面按工程结构、转码链路、加密实现、播放联动、排错、二次开发的顺序一层层拆。

2. 工程结构与技术选型:UI、FFmpeg、VLC 三层的职责边界

2.1 三个项目、一条依赖线

工程不是一个大 csproj 堆到底,而是切成了三层:UI 只负责界面与用户操作,Core 放转码、加解密、任务队列这些可测试的逻辑,Cli 是一个无界面的命令行入口,方便批处理和自动化验证。这个切法很实用——UI 层换皮不影响核心,Cli 层又能拿来跑回归。

VideoCrypt/ ├─ src/ │ ├─ VideoCrypt.UI/ # WinForms 主工程 │ │ ├─ Forms/MainForm.cs # 主窗体,无边框 + 自绘标题栏 │ │ ├─ Controls/DropZonePanel.cs # 拖拽导入区域 │ │ ├─ Controls/TaskItemControl.cs # 任务列表单项 │ │ └─ Theme/DarkTheme.cs # 配色与圆角、阴影参数 │ ├─ VideoCrypt.Core/ │ │ ├─ Transcoding/ # ffmpeg / ffprobe 封装 │ │ ├─ Cryptography/ # 文件头、分块 AES、HMAC │ │ ├─ Playback/ # VLC 封装与临时解密 │ │ └─ Models/ # 任务、配置、进度事件 │ └─ VideoCrypt.Cli/ # 命令行入口,参数解析 + 批处理 ├─ third_party/ffmpeg/ # ffmpeg.exe、ffprobe.exe 及依赖 dll └─ VideoCrypt.sln

third_party/ffmpeg这个目录位置是有讲究的:它不放在 bin 下,而是作为解决方案里的内容文件,通过 csproj 的<Content CopyToOutputDirectory="PreserveNewest">复制到输出目录。这样调试和发布走的是同一套相对路径逻辑,少一个「本机能跑、换机就崩」的经典问题。

2.2 为什么是 FFmpeg 而不是系统自带解码器

转码这块,Windows 自带的 Media Foundation 也能干,但参数控制粒度太粗,码率、preset、CRF、像素格式这些调不了,遇到特殊封装(比如某些 mkv 变体)直接撂挑子。FFmpeg 的命令行是最稳的抽象层:参数即配置,出错有 stderr,进度有-progress,出了问题还能把命令行原样复制到终端复现。

播放这块选 VLC 而不是系统播放器,理由更直接——VLC 的解码器和容器支持范围几乎是桌面端最宽的,而且有成熟的 .NET 封装,能嵌进 WinForms 面板里当控件用。代价是它不认识你自定义的加密容器,这一点后面第 5 章会专门说。

三层之间的边界我一般这么划:UI 不许出现Process.Start,Core 不许引用System.Windows.Forms。做到这两条,Core 就能脱离界面单测,Cli 也能直接复用。

2.3 拖拽导入与文件校验:UI 层最容易写脏的地方

拖拽是这类工具的第一入口,很多人直接在DragDrop事件里读文件、开线程、更新进度,写着写着就变成一坨。工程里的做法是:拖拽只做「收集 + 校验 + 入队」,真正的活交给任务队列。

private void OnFilesDropped(object? sender, DragEventArgs e) { if (e.Data?.GetData(DataFormats.FileDrop) is not string[] paths) return; foreach (var path in paths) { // 只接受常见视频容器,避免用户误拖一个 txt 进来 var ext = Path.GetExtension(path).ToLowerInvariant(); if (!SupportedExtensions.Contains(ext)) continue; var info = new FileInfo(path); if (!info.Exists || info.Length == 0) continue; // 关键:这里只入队,不启动任何耗时操作 _queue.Enqueue(new TranscodeTask { InputPath = path, OutputPath = BuildOutputPath(path), Profile = _currentProfile, State = TaskState.Pending }); } RefreshTaskList(); }

逻辑上三段:先判空、再按扩展名白名单过滤、最后只做入队。BuildOutputPath负责在源文件同目录生成xxx_enc.mp4这类不冲突的名字,避免覆盖原文件——这是个很小但很救命的细节,我之前见过直接原地覆盖的实现,用户一点就没了原始素材。

提示:白名单校验要放在拖拽入口,而不是转码线程里。放到后面意味着用户拖 20 个文件进去,你要等 20 次失败才知道全都不支持。

3. 转码模块拆解:从 ffmpeg.exe 调用到进度条不抽搐

3.1 一套能覆盖九成场景的参数模板

转码参数不追求全覆盖,追求「默认值合理、改一个参数就能变风格」。工程里把参数收进一个 Profile 对象,UI 上只暴露「清晰度」和「体积优先/速度优先」两个下拉,背后映射到具体参数。

public sealed class TranscodeProfile { public string VideoCodec { get; init; } = "libx264"; public string Preset { get; init; } = "medium"; // ultrafast ~ veryslow public int Crf { get; init; } = 23; // 18 高清,28 体积小 public string AudioCodec { get; init; } = "aac"; public int AudioKbps { get; init; } = 128; public string? Scale { get; init; } // 例如 "scale=-2:720" public List<string> BuildArguments(string input, string output) { var args = new List<string> { "-hide_banner", "-nostats", "-progress", "pipe:1", // 结构化进度输出到 stdout "-y", "-i", input, "-c:v", VideoCodec, "-preset", Preset, "-crf", Crf.ToString(CultureInfo.InvariantCulture), "-pix_fmt", "yuv420p" // 兼容性优先,别用 yuv444p }; if (!string.IsNullOrWhiteSpace(Scale)) { args.Add("-vf"); args.Add(Scale); } args.AddRange(new[] { "-c:a", AudioCodec, "-b:a", $"{AudioKbps}k", "-movflags", "+faststart", // moov 前置,网页播放首帧更快 output }); return args; } }

几个参数值得单独说。-crf是恒定质量模式,18 肉眼几乎无损、23 是默认平衡点、28 开始能看出块效应,它和-preset一起决定「画质/体积/耗时」三角。-preset不影响画质只影响编码耗时,medium是安全区,赶时间用veryfast,存档用slow。-pix_fmt yuv420p这一行很多人省掉,然后在某些播放器上得到一片绿屏,血泪经验。

scale=-2:720里的-2是「按比例算、并保证结果是偶数」,因为 H.264 的 yuv420p 要求宽高都是偶数,写-1会在一半的片源上直接报错。

3.2 进度解析:别再用正则抓 time= 了

老教程里的做法是从 stderr 里正则抓time=00:00:12.34,问题是 ffmpeg 用\r刷新同一行,ErrorDataReceived按行切分时行为不稳定,进度条经常从 0 直接跳 100。更稳的是-progress pipe:1,它按 key=value 逐行输出到 stdout:

frame=123 fps=45.6 out_time_us=5120000 progress=continue

解析就变成了字符串前缀匹配,不用碰正则。

public async Task RunAsync( IReadOnlyList<string> args, long totalUs, IProgress<double> progress, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = FfmpegPath, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; // ArgumentList 逐个塞,天然处理空格和中文路径,不用手拼引号 foreach (var a in args) psi.ArgumentList.Add(a); using var proc = new Process { StartInfo = psi }; proc.OutputDataReceived += (_, e) => { if (e.Data is null) return; var line = e.Data.AsSpan(); if (!line.StartsWith("out_time_us=")) return; if (!long.TryParse(line[12..], out var us)) return; var ratio = totalUs > 0 ? Math.Clamp((double)us / totalUs, 0, 1) : 0; progress.Report(ratio); }; proc.Start(); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); // 必须消费 stderr,否则管道写满会阻塞 ffmpeg using var reg = ct.Register(() => TryKill(proc)); await proc.WaitForExitAsync(ct); if (proc.ExitCode != 0) throw new TranscodeException(proc.ExitCode, _stderrTail.ToString()); }

这里有两个容易忽略的点。BeginErrorReadLine那行不是可选的——ffmpeg 往 stderr 写日志很凶,你不读,管道缓冲区满了它就会卡在那里等,表现就是「转码卡在 37% 不动」。ArgumentList比手拼字符串安全得多,路径里带空格、中文、方括号都不会出问题。

totalUs从 ffprobe 拿,不要试图从 ffmpeg 的输出里猜:

public static async Task<long> ProbeDurationUsAsync(string ffprobe, string input) { var psi = new ProcessStartInfo(ffprobe) { UseShellExecute = false, RedirectStandardOutput = true }; 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=nw=1:nk=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 sec) ? (long)(sec * 1_000_000) : 0; }

-of default=nw=1:nk=1的作用是去掉duration=这个键名和单位,只留裸数字,省一次字符串切割。解析用InvariantCulture,否则在某些区域设置下小数点会被当成千分位。

3.3 取消与进程回收:别留僵尸 ffmpeg

用户点「取消」的时候,只Kill()父进程是不够的,ffmpeg 在某些封装下会拉起子进程,父进程死了子进程还在往文件里写,最后得到一个半截的、播放器打不开的 mp4。

private static void TryKill(Process p) { try { if (p.HasExited) return; p.Kill(entireProcessTree: true); // 连带子进程一起收 p.WaitForExit(3000); } catch (InvalidOperationException) { /* 已经退出了,忽略 */ } catch (System.ComponentModel.Win32Exception) { /* 权限不足,记录日志 */ } }

entireProcessTree: true是 .NET Core 3.0 之后才有的重载,老框架上得退化成taskkill /PID xxx /T /F。杀完之后还有一步:清理输出目录里的残留临时文件。工程里所有转码都先写xxx.part,成功后才File.Move改成正式名,取消时直接删.part,用户永远看不到半成品。

4. 加密模块拆解:分块 AES 与自定义文件头

4.1 文件格式设计:把元信息放在最前面

直接对整个文件跑 AES 是最省事的写法,但那样加密后的文件就是一块没有结构的密文,播放器不知道它是什么、你也不知道原始扩展名。所以工程自定义了一个轻量容器,头部明文存放必要元信息,后面跟密文和校验尾。

字段长度说明
Magic4 字节固定VENC,用于快速识别文件类型
Version1 字节格式版本,为将来换算法留口子
Flags1 字节位标记,目前用于标识是否带 HMAC
Salt16 字节PBKDF2 派生密钥用,每个文件独立随机
IV16 字节AES-CBC 初始向量,同样每文件随机
PlainLength8 字节原始文件字节数,解密时用于预分配与进度
ExtLength1 字节原始扩展名长度
Ext变长UTF-8 编码的扩展名,如.mp4
Cipher变长AES-256-CBC 密文体
Hmac32 字节对「头部 + 密文」的 HMAC-SHA256

头部明文是刻意的取舍:元信息本身不敏感,放明文的代价是暴露原始大小和类型,收益是解密前就能校验密码对不对、能显示进度、能还原扩展名。如果你的场景连文件大小都要藏,那把 PlainLength 挪进密文体即可,代价是解密必须从头顺序读。

4.2 分块加解密:1 MB 一块,内存恒定

一个 4 GB 的视频读进byte[]会直接 OOM,所以必须流式处理。好消息是CryptoStream本身就支持流式,你只要控制读写块大小就行。

public static void Encrypt( string input, string output, string password, IProgress<double>? progress = null) { const int BlockSize = 1 << 20; // 1 MB var salt = RandomNumberGenerator.GetBytes(16); var key = DeriveKey(password, salt); // 32 字节,见 4.3 using var aes = Aes.Create(); aes.Key = key; aes.GenerateIV(); var src = new FileInfo(input); using var outFs = new FileStream( output, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 64 * 1024, FileOptions.SequentialScan); WriteHeader(outFs, new VaultHeader(1, 1, salt, aes.IV, src.Length, src.Extension)); using var hmac = new HMACSHA256(key); using (var crypto = new CryptoStream( outFs, aes.CreateEncryptor(), CryptoStreamMode.Write, leaveOpen: true)) { var buffer = new byte[BlockSize]; using var inFs = new FileStream(input, FileMode.Open, FileAccess.Read, FileShare.Read); long done = 0; int read; while ((read = inFs.Read(buffer, 0, buffer.Length)) > 0) { crypto.Write(buffer, 0, read); hmac.TransformBlock(buffer, 0, read, null, 0); done += read; progress?.Report((double)done / src.Length); } crypto.FlushFinalBlock(); // 补 PKCS7 padding } // leaveOpen 保证 outFs 还能继续写 hmac.TransformFinalBlock(Array.Empty<byte>(), 0, 0); outFs.Write(hmac.Hash!); // 尾部 32 字节校验 }

leaveOpen: true是这段代码的关键。CryptoStream释放时默认会连底层流一起关掉,那样后面的 HMAC 就没地方写了,表现为「加密成功但解密总报校验失败」——我见过有人为了绕开这个,把 HMAC 单独写一个.hmac文件,等于把容器拆成了两个文件,管理成本翻倍。

解密是镜像过程:先读头、校验魔数和版本、用同样的 salt 派生 key、验证尾部 HMAC 是否匹配(不匹配直接报「密码错误或文件损坏」,别硬解),然后再流式解出原文。

public static void Decrypt(string input, string output, string password) { using var inFs = new FileStream(input, FileMode.Open, FileAccess.Read, FileShare.Read); var header = ReadHeader(inFs); if (header.Magic != "VENC") throw new InvalidDataException("不是本程序生成的加密文件"); var key = DeriveKey(password, header.Salt); // 先验 HMAC,密码错就不用往下走 if (!VerifyHmac(inFs, key, header)) throw new CryptographicException("密码错误或文件被篡改"); inFs.Seek(header.CipherOffset, SeekOrigin.Begin); using var aes = Aes.Create(); aes.Key = key; aes.IV = header.Iv; using var outFs = new FileStream(output, FileMode.Create, FileAccess.Write); using var crypto = new CryptoStream(inFs, aes.CreateDecryptor(), CryptoStreamMode.Read, leaveOpen: true); var buffer = new byte[1 << 20]; int read; while ((read = crypto.Read(buffer, 0, buffer.Length)) > 0) outFs.Write(buffer, 0, read); }

先验 HMAC 再解密,这个顺序很重要。反过来的话,密码错了也要把整个文件跑一遍 CBC 解密,一个 2 GB 的视频要白等十几秒才告诉你密码不对,体验很差。

4.3 密钥派生与完整性:别拿密码字节当密钥

直接Encoding.UTF8.GetBytes(password)填充到 32 字节是很多示例代码的做法,问题是密码熵低、没有盐,同一密码加密的两个文件密钥完全相同,攻击者可以批量比对。正确做法是 PBKDF2:

private static byte[] DeriveKey(string password, byte[] salt) { // 10 万次迭代是当前比较平衡的值,太低不安全,太高中低端机明显卡顿 using var kdf = new Rfc2898DeriveBytes( password, salt, iterations: 100_000, HashAlgorithmName.SHA256); return kdf.GetBytes(32); // AES-256 }

salt 和 IV 都是每文件随机生成、跟着文件走的,不是全局常量——这是最容易写错的地方。密钥只在内存里存在,不进配置文件、不写日志、不拼进命令行参数(命令行参数在任务管理器里能被别的进程看到)。

5. 常见问题排查:转码卡住、解密花屏、换机找不到 exe

5.1 转码链路的问题

现象一:进度条停在某个百分比不动,任务管理器里 ffmpeg 还在跑但 CPU 很低。

原因基本可以确定是 stderr 管道写满了。ffmpeg 默认会往 stderr 输出每一帧的统计信息,你不消费它,操作系统的匿名管道缓冲区(通常 4 KB 左右)满了之后,ffmpeg 的写操作就会阻塞,进程挂在那里等。解决就是把BeginErrorReadLine加上,或者干脆用-loglevel error -nostats把日志压到最低。我一般两个都做,日志另存一份到文件,出问题时能查。

现象二:中文路径或带空格的目录,一跑就报No such file or directory。

原因是参数拼接时没加引号,-i D:\我的视频\a.mp4被拆成了两个参数。彻底的办法是别拼字符串,用ProcessStartInfo.ArgumentList一个一个塞,运行库会处理转义。如果必须拼(比如要显示命令行给用户看),那就所有含路径的参数都加双引号,并且把StandardErrorEncoding设成 UTF8,否则中文错误信息会变问号,你连它报的什么错都看不懂。

现象三:转码到一半界面完全卡死,鼠标都动不了。

十有八九是在 UI 线程上写了proc.StandardError.ReadToEnd()这种同步阻塞调用,而且这个调用会一直等到进程结束。改成BeginErrorReadLine+ 事件回调,或者整个转码跑在Task.Run里,进度通过IProgress<T>回主线程。注意IProgress<T>.Report内部会SynchronizationContext.Post回 UI 线程,所以事件回调里直接调是安全的。

5.2 加解密与播放的问题

现象四:加密后文件比原文件大了好几倍。

正常情况只应该多出「头部约 50 字节 + PKCS7 补齐最多 16 字节 + HMAC 32 字节」。如果大了一倍以上,八成是循环里重复写了头部,或者把原文件和密文都留在了输出流里。排查方法很土但有效:拿一个 1 KB 的小文本文件试,加密后应该只有 1.1 KB 左右,数字对不上就直接看WriteHeader被调用了几次。

现象五:VLC 播放解密后的文件,前 30 秒正常,后面花屏或直接卡死。

典型的分块解密没处理最后一块。CryptoStream在CryptoStreamMode.Read下,最后一次Read返回的是去掉 padding 之后的真实数据,必须老老实实循环读到返回 0 为止;如果按固定块大小读、忽略返回值的实际长度,就会把 padding 字节也写进文件,尾部若干个字节是垃圾。播放器在文件末尾遇到非法 NAL 单元,表现就是花屏或者提前结束。

现象六:直接把加密文件丢给 VLC,黑屏但有声音,或者干脆打不开。

这不是 bug,是设计边界。VLC 认的是标准容器(mp4/mkv/avi 这些),你自定义的VENC头它不认识,会当成损坏文件处理。工程里的做法是「播放前先解密到临时文件」,播完删掉。临时文件方案简单可靠,缺点是明文落盘。要更严一点,就得走 LibVLC 的自定义输入回调,边解边喂,这块复杂度会上去一个台阶,不是所有场景都值得。

注意:临时文件一定要在Dispose里删,而且要处理播放器还持有文件句柄导致删除失败的情况,常见做法是延迟删除或者下次启动时清理残留。

5.3 部署与打包的问题

现象七:本机调试一切正常,打包发给别人就提示找不到 ffmpeg。

相对路径的基准是「当前工作目录」,不是「exe 所在目录」。双击运行时两者恰好一致,但从快捷方式、计划任务、或者别的程序调起时就不一致了,于是third_party/ffmpeg/ffmpeg.exe找不到。统一改成Path.Combine(AppContext.BaseDirectory, "third_party", "ffmpeg", "ffmpeg.exe"),AppContext.BaseDirectory永远指向 exe 所在目录,不受工作目录影响。

现象八:用户机器上 ffmpeg 进程起不来,或者刚启动就被安全软件拦掉。

无外乎两个原因:一是发布时漏了 ffmpeg 依赖的 dll(某些构建需要额外的运行库),二是可执行文件没有签名,被安全软件当成可疑程序。前者用依赖查看工具确认一遍,后者只能走正常签名流程,短期绕不开。真要规避,可以换成把 ffmpeg 编成静态库、通过 P/Invoke 调用,代价是打包体积和集成复杂度都上去了。

6. 二次开发:把单机工具改成能对接业务的样子

这份源码最值钱的不是界面,是 Core 层的分层。想改造成能用的东西,第一件事是把「一个文件一次操作」变成「一批文件一条流水线」。工程里已经有TranscodeTask这个模型,改造的核心是给它加上状态机和重试。

改造点现状建议做法
批量处理单文件串行任务队列 + 并发数限制(建议 = CPU 核数 / 2)
失败重试直接抛异常按错误码分类,IO 类错误重试 2 次,参数类错误不重试
密钥管理每次手输密码接系统凭据存储,密码不进内存超过必要时间
接口复用引用 Core 项目抽出ITranscoder/IVaultCodec两个接口,方便换实现

抽接口这件事看着像过度设计,实际改起来省事。比如你后面想换成硬件编码(NVENC/QSV),只要新增一个NvencTranscoder : ITranscoder,UI 一行不用动。加密算法想从 AES-CBC 换成 AES-GCM(顺带把 HMAC 省掉),同样只换IVaultCodec的实现,文件头里的 Version 字段就是给这种场景留的。

另一个很实用的改造是给 Cli 项目加一个--watch模式,监听某个目录,新文件进来自动转码加密。落地到实际业务里,这比任何花哨的界面都管用——毕竟没人愿意天天手动拖文件。

# 改造后可以直接跑批,配合计划任务就是一条流水线 VideoCrypt.Cli.exe encrypt --input "D:\raw" --output "D:\vault" --profile 720p

最后说个我自己的教训。这类工具最怕的不是功能少,是「加密完了解不回来」。从那以后我每次改完加解密相关代码,都强制走一遍「加密一个 10 秒的小视频 → 用错误密码解一次确认被拒 → 用正确密码解一次确认能播 → 比对原始文件的 SHA256」这个四步,一步都不能省。密钥派生、文件头偏移、padding 处理这三处只要有一处改错,症状都是「解密成功但文件是坏的」,而且往往要播到中间才发现。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询