
简介这是一套面向具备一定 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.slnthird_party/ffmpeg这个目录位置是有讲究的它不放在 bin 下而是作为解决方案里的内容文件通过 csproj 的Content CopyToOutputDirectoryPreserveNewest复制到输出目录。这样调试和发布走的是同一套相对路径逻辑少一个「本机能跑、换机就崩」的经典问题。2.2 为什么是 FFmpeg 而不是系统自带解码器转码这块Windows 自带的 Media Foundation 也能干但参数控制粒度太粗码率、preset、CRF、像素格式这些调不了遇到特殊封装比如某些 mkv 变体直接撂挑子。FFmpeg 的命令行是最稳的抽象层参数即配置出错有 stderr进度有-progress出了问题还能把命令行原样复制到终端复现。播放这块选 VLC 而不是系统播放器理由更直接——VLC 的解码器和容器支持范围几乎是桌面端最宽的而且有成熟的 .NET 封装能嵌进 WinForms 面板里当控件用。代价是它不认识你自定义的加密容器这一点后面第 5 章会专门说。三层之间的边界我一般这么划UI 不许出现Process.StartCore 不许引用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 Liststring BuildArguments(string input, string output) { var args new Liststring { -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 里正则抓time00:00:12.34问题是 ffmpeg 用\r刷新同一行ErrorDataReceived按行切分时行为不稳定进度条经常从 0 直接跳 100。更稳的是-progress pipe:1它按 keyvalue 逐行输出到 stdoutframe123 fps45.6 out_time_us5120000 progresscontinue解析就变成了字符串前缀匹配不用碰正则。public async Task RunAsync( IReadOnlyListstring args, long totalUs, IProgressdouble 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 Tasklong 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(formatduration); psi.ArgumentList.Add(-of); psi.ArgumentList.Add(defaultnw1:nk1); 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 defaultnw1:nk1的作用是去掉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 字节位标记目前用于标识是否带 HMACSalt16 字节PBKDF2 派生密钥用每个文件独立随机IV16 字节AES-CBC 初始向量同样每文件随机PlainLength8 字节原始文件字节数解密时用于预分配与进度ExtLength1 字节原始扩展名长度Ext变长UTF-8 编码的扩展名如.mp4Cipher变长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, IProgressdouble? 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.Emptybyte(), 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 字节是很多示例代码的做法问题是密码熵低、没有盐同一密码加密的两个文件密钥完全相同攻击者可以批量比对。正确做法是 PBKDF2private 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. 常见问题排查转码卡住、解密花屏、换机找不到 exe5.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里进度通过IProgressT回主线程。注意IProgressT.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 : ITranscoderUI 一行不用动。加密算法想从 AES-CBC 换成 AES-GCM顺带把 HMAC 省掉同样只换IVaultCodec的实现文件头里的 Version 字段就是给这种场景留的。另一个很实用的改造是给 Cli 项目加一个--watch模式监听某个目录新文件进来自动转码加密。落地到实际业务里这比任何花哨的界面都管用——毕竟没人愿意天天手动拖文件。# 改造后可以直接跑批配合计划任务就是一条流水线 VideoCrypt.Cli.exe encrypt --input D:\raw --output D:\vault --profile 720p最后说个我自己的教训。这类工具最怕的不是功能少是「加密完了解不回来」。从那以后我每次改完加解密相关代码都强制走一遍「加密一个 10 秒的小视频 → 用错误密码解一次确认被拒 → 用正确密码解一次确认能播 → 比对原始文件的 SHA256」这个四步一步都不能省。密钥派生、文件头偏移、padding 处理这三处只要有一处改错症状都是「解密成功但文件是坏的」而且往往要播到中间才发现。希望帮到你。本文还有配套的精品资源点击获取