把一个 macOS 录屏编辑器搬到 Windows:ScreenCam Windows 版的架构
WinUI 3 做外壳,WebView2 里跑 React 编辑器,C++ 原生桥负责采集、合成和编码。下面讲这几层怎么分工,以及几次走不通之后改了什么。
- 目标:行为一致,不逐行翻译
- 全景:四层、三种语言
- .cam 包:跨平台的契约
- 录制管线
- 编辑器:WebView2 + React
- 唯一的 reducer 在 C#
- 预览像素:一个盖在网页上的原生窗口
- 导出:和预览同一张渲染图
- 去掉 FFmpeg
- 截图
- 验收:证据而不是感觉
- 回头看
01目标:行为一致,不逐行翻译
ScreenCam 先有 macOS 版:录屏时把屏幕画面、光标轨迹、按键、摄像头和两路音频分开存,进编辑器后再自动缩放跟随光标、加背景和边框、做镜头运动,最后导出成片。Windows 版从 2026 年 5 月 28 日的一份技术设计开始,到今天 main 上 719 个提交。
设计文档第一句定了方向:不按 SwiftUI/AppKit 代码逐行翻译。要一致的是产品行为、工程边界、工作区格式、镜头规划和导出结果;平台层用 Windows 自己的能力重写。这条原则后面每个决定都会回到:macOS 是行为的参照(仓库的 AGENTS.md 写作 “upstream behavior is the source of truth”),Windows 不照搬实现。
02全景:四层、三种语言
最初的设计是“WinUI 3 + XAML 写全部界面”。实际落地后,编辑器界面换成了 WebView2 里的 React,其余照原计划:
- WinUI 3 宿主(C#):窗口、录制工具栏、区域选择 HUD、对话框、文件激活、商店购买,以及 WebView2 的托管和消息分发。
- Web 编辑器(React 19 + Zustand):时间线、检查器、播放条、交互状态。它只拿数据、发意图,不碰文件,不定义导出结果。
- App / Core(C#):
EditorDocument、30 种EditorCommand、reducer、时间线校正、镜头规划与求值、.cam读写。 - NativeBridge(C++20):Windows.Graphics.Capture、D3D11 合成、Media Foundation 编解码、DirectShow 摄像头、预览交换链。对外只有一层 C ABI(
sc_*函数)。
语言边界按“谁需要贴近硬件”来划:C# 负责 UI、编排、JSON、撤销重做和授权;C++ 负责 WGC、D3D11、Media Foundation 和高频时间戳。设计文档特意没选 C++/CLI 做核心边界,用的是 C ABI 加 P/Invoke。这样原生 DLL 可以单独版本化:各项原生能力探测时都会先调 sc_bridge_get_contract_version,和 C# 侧写死的期望版本(目前是 69)比对,不一致就把对应能力标成不可用,而不是靠文件名或时间戳去猜。
03.cam 包:跨平台的契约
两个平台之间真正共享的是 .cam 这个目录格式,而不是代码。编辑器、CLI、agent 工作流都围着它转,Windows 版从第一天就用 macOS 的布局作为准绳:
Recording.cam/
manifest.json # format / schemaVersion / 各文件相对路径
project.json
recording/
manifest.json # 采集证据:displayID、captureRect、nominalFrameRate…
events/ cursor.json keyboard.json
media/ display-0.mp4 camera-0.mp4 system-0.m4a microphone-0.m4a
diagnostics/ # Windows 独有:capture-target、encoder、audio、input…
edit/
timeline.json camera.json frame.json cursor.json
motion.json audio.json preview-audio.json
几条兼容规则决定了 Windows 能往里加什么:
- 录制视频里不烘焙任何效果。光标、按键、摄像头画中画、边框、镜头运动全部是单独的元数据或媒体,进编辑器才合成。WGC 采集时直接关掉了系统光标(
IsCursorCaptureEnabled(false)),光标从输入事件里重画。 - Windows 专有信息只放诊断目录。
recording/manifest.json的capture对象保持 macOS 形状,sourceDeviceType恒为"macOS";目标类型、HDR 色彩信息等写到recording/diagnostics/capture-target.json,校验器会拒绝把这些字段塞进主清单。这样 macOS 读包时不用认识新的枚举值。 - 路径只能是包内相对路径,
..逃逸和绝对路径直接判为无效;不认识的可选字段要么原样保留,要么安全忽略。 - 写包是原子的:先写进
materializing-<guid>暂存目录,媒体拷进去,再整体移动到位,I/O 失败按 50/150/300 ms 重试。
用了 3D 镜头(spatialCamera)的工程会把 schemaVersion 和 minimumReaderSchemaVersion 升到 3,其余保持 2,老版本读到新特性时会明确拒绝,而不是读错。
04录制管线
录制是整个项目风险最高的部分,所以排在编辑器前面做。研究文档里定的验收线不是“能录”,而是 CPU、GPU、内存、队列深度、音频漂移、丢帧都有界。管线的形状:
视频:纹理一直待在 GPU 上
采集用 Windows.Graphics.Capture,帧池用 CreateFreeThreaded 建,不依赖 UI 线程的 DispatcherQueue。帧池 3 个缓冲:一个留给读取端,两个空给 DWM。区域录制没有单独的 API:采整块显示器,再用 CopySubresourceRegion 在 GPU 上裁,需要缩放时走着色器。从采集到编码器,像素不回 CPU。
帧节奏:FrameArrived 只拨一个事件,读取端带 100 ms 截止时间等它。总是拿最新的一帧,没到时间的旧帧直接关掉,相邻输出帧至少间隔 3/4 个帧周期。时间戳用的是 DWM 合成时刻(SystemRelativeTime),不是轮询拿到帧的时刻。背压的硬上限在编码器:Media Foundation 在途样本默认最多 8 个,再多就有界地等 2 秒。
HDR 显示器按 FP16 scRGB 采集,但录制输出始终是 SDR 8-bit:按系统的 SDR 白点(至少 80 nit)归一,再统一压高光。H.264 用 High profile、无 B 帧,GOP 取 min(fps, 30),码率按“像素 × 帧率 × 0.06–0.08 bpp”估算,1080p60 大约 8.7 Mbps。
有一个实现细节能看出 Windows 媒体栈的真实情况:编码完成后还要原地修补 MP4 里的颜色标签(VUI / avcC / colr),因为 NVIDIA 的 MFT 会在 VUI 里写 0。修完才能保证各种播放器按 BT.709 解释颜色。
音频、输入、摄像头:各自独立的旁路文件
系统声音用 WASAPI loopback,麦克风用 WASAPI 共享模式,每 20 ms 轮询一次,每个包带上 GetBuffer 给的 QPC 时间,先写进磁盘上的临时 spool。停止时 AudioSidecarTimeline 把时间戳换算成采样位置:超过 10 ms 的缺口补静音,重叠部分丢掉。系统没声音时 loopback 不出包、首包晚到、时钟漂移,这三种情况都靠这一步解决。设备中途拔掉,录制继续,对应时段是静音。
键鼠事件用 Raw Input(RIDEV_INPUTSINK,独立消息线程),同时开 WH_KEYBOARD_LL / WH_MOUSE_LL 低级钩子兜底,两路合并。光标位置不靠钩子,按 50 ms 采样 GetCursorPos / GetCursorInfo,连光标形状一起记。
摄像头有一条写进仓库规则的隐私约束:只按用户明确选中的设备 id 打开,不存在“默认摄像头”或“第一个枚举到的”兜底。设备不在就不录摄像头并报警告,绝不换一个开。普通设备走 WinRT MediaFrameReader;OBS 虚拟摄像头这类只注册了 DirectShow 的设备,走原生 DirectShow 图(Source → Sample Grabber → Null Renderer)。枚举设备时只读元数据,不激活滤镜:以前用 FFmpeg -list_devices 枚举会激活第三方驱动,有的驱动会在录制开始前就崩掉。
录制界面不进画面
录制工具栏、区域选择的遮罩和手柄、摄像头气泡都设置了 SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE),录出来的视频里没有它们。区域选择 HUD 没用 XAML,是一组 Win32 分层窗口(遮罩、输入层、4 条边框、4 条内描边、8 个手柄),用 UpdateLayeredWindow 画,用完缓存起来复用。命中测试和区域状态机放在 C# 的 RecordExportSelectionHudViewModel 里,录屏和截图的选区共用这一份;网页侧只渲染快照、转发输入,不允许再写一套 JS 几何状态机。
05编辑器:WebView2 + React
这是整个项目最大的一次转向。6 月 19 日的录制/导出 MVP 里,编辑器是纯 WinUI 写的;6 月 23 日的 RFC 决定把编辑器界面整体搬进 WebView2。理由写得很直接:复杂的编辑器布局、时间线交互、预览叠层和检查器控件,用一大棵 XAML 控件树写太慢;WinUI 应该只管窗口、文件、WebView2 托管、原生求值和导出、系统集成。旧的 XAML 编辑器代码在 10 月 17 日删干净(-2,825 行)。
(浏览器 mock 模式下是空的)
?scenario=camera-media&locale=zh-Hans)。顶栏、检查器、时间线全是 React;时间线上的录制、摄像头、标注、两路音频和两段缩放(一段 2.2×,一段 3D 50 mm f/2.8)都来自 mock 数据。预览区为什么是空的,见第 7 节。RFC 给编辑器定了六层,每层只认识下一层的接口:
| 层 | 负责 |
|---|---|
| Editor UI | React 组件、CSS、直接操作、快捷键 |
| Editor Runtime | Zustand store(controls / exporting / playback / selection / ui / workspace 六个 slice)、拖拽控制器、草稿与提交 |
| Host Adapter | EditorHostAdapter:唯一一个碰 window.chrome.webview 的地方 |
| Mock Host | 1,492 行的 editorMockBridge.ts,伪造宿主,支持十几种场景 |
| WebView2 Host | 注入的 payload、真实消息桥、虚拟主机媒体 URL |
| Native / Core | .cam 读写、命令、求值、渲染、导出 |
mock 层是一等公民。RFC 原话:一个功能如果只能在 WinUI 宿主里手动点出来、没法在浏览器 mock 里跑,就不算“编辑器就绪”。这给了项目两条独立的迭代回路:界面和交互在普通浏览器里用 npm run dev 和 Playwright 迭代,几秒一轮;需要真实媒体和原生能力时,再进 WinUI。123 个 Playwright 用例文件大多跑在 mock 宿主上。
宿主侧的几个工程细节:
- Vite 用
vite-plugin-singlefile把整个编辑器打成一个内联 HTML。宿主把工程数据 JSON 替换进占位符__SCREENCAM_EDITOR_PAYLOAD_JSON__,生成index-{generation}.html,首帧不用再走一次消息往返。 - 四个虚拟主机名:
screencam-editor-bootstrap.local(生成的 HTML)、-static.local(静态资源)、-package.local(当前.cam包里的媒体)、-derived.local(拖动代理缓存)。网页拿不到文件系统路径,只能访问这几个映射。 - 消息信封是
{version, type, requestId, payload},网页到宿主约 60 种,宿主到网页约 37 种。来自过期导航的消息直接丢弃。 - 11 个 control domain(frame、cursor、motion、audio、cameraOverlay…)各有一个描述符,声明自己对应哪个 action、是否被原生预览覆盖。三个 lint 脚本(架构棘轮、control domain、状态归属)在 CI 里防止结构退化。
06唯一的 reducer 在 C#
把界面搬进网页,最大的风险是网页慢慢长出第二套业务逻辑。RFC 的规定是:任何改文档的操作,要么本身是 EditorCommandEnvelope,要么是一个临时 action、在 ScreenCam.App 里立刻翻译成命令,“临时 action 不能变成第二个 reducer”。
落到代码上,权威的 EditorCommandReducer.Apply(1,900 行,纯函数,对不可变记录做 with 拷贝)只在 C# 里。网页里有草稿,但草稿不会落盘。一次拖动滑杆的完整往返:
evaluateFrame 发给宿主,宿主套在一次性副本上渲染预览,文档本身不变;松手才发 editorAction。宿主回的补丁带连续修订号,网页发现跳号就整份重拉。撤销/重做存的是整个 EditorDocument 快照,带同一个 undoGroupId 的编辑合成一步。时间线校正(TimelineReconciler)也在 C#:录制片段最短 500 ms,缩放片段最短 1 s,键盘片段最短 120 ms。镜头运动是弹簧模型,默认质量 2.6、刚度 160、阻尼 42,参数和 macOS 保持一致,镜头、命令、时间线求值都有共享的 JSON fixture 做回归。
唯一的例外是 mock 宿主:它为了脱离 C# 跑起来,自己在 TS 里实现了一遍变更。所以 mock 只用来验证界面和交互,文档语义的测试都在 C# 侧。
07预览像素:一个盖在网页上的原生窗口
编辑器搬进 WebView2 之后,预览一开始也在网页里:DOM 的 <video> 加 CSS 叠层。问题很快暴露:预览和导出各算各的,CSS 的圆角、阴影、裁剪、镜头运动和 D3D 导出总差那么一点。中间有过一个过渡方案,原生渲染一帧,转成 PNG 给网页 <img> 显示。每帧要经过 GPU 回读、PNG 编码、写文件、网页解码,只能当诊断工具用。
最后定下的不变式是:
同一条求值代码路径 + 同一个原生合成器 → 预览把纹理 Present 出来,导出把纹理送进编码器。
预览成了一个原生子窗口:C++ 侧建一个 WS_CHILD 窗口,挂一条 FLIP_DISCARD、双缓冲的 DXGI 交换链,放在编辑器 HWND 里、WebView2 之上(HWND_TOP)。网页把预览区的矩形通过 nativePreviewBounds 报给宿主,宿主移动子窗口跟上。所以图 3 里浏览器 mock 的预览区是空的:真实应用里那块由原生窗口出像素。
这个“空域”问题牵出了一串设计。对话框原来是 WinUI 的 ContentDialog,打开时要先把预览窗口藏起来,太高的对话框还会被裁切;现在每个对话框都是 owner 指向编辑器的独立顶层窗口,打开时禁用编辑器,任何关闭路径(按钮、Esc、Alt+F4、显示失败)都要重新启用并激活编辑器。新窗口都经过 WindowRevealGate:先用 DWM cloak 藏着,等内容加载完、尺寸稳定一帧、合成器提交确认、DwmFlush 之后才显示,避免首帧空白闪一下(验收要求首个可见帧和稳定帧的像素差 ≤1%)。
8 月 21 日试过另一条路:用 XAML 的 SwapChainPanel 代替子 HWND,这样 XAML 和网页内容可以盖住预览。实验记录很诚实:方向有用,但“显示节奏不可观测、指针到 Present 的延迟其实没量到、真实混合 DPI 拖动在手头硬件上跑不了、108 个模态叠层组合没有一个完全达标”,结论是 NO-GO,子 HWND 继续是默认方案,并列出了下次再试之前必须先满足的六个条件。
渲染图与调度
预览按 60 Hz 调度,挂在 CompositionTarget.Rendering 上,按 QPC 截止时间走,错过的拍子直接跳过、不补发;同时只跑一个 tick,新的意图覆盖旧的待处理意图。解码用 Media Foundation Source Reader,开 DXVA 硬件解码,直接出 NV12 纹理。合成在 sc_render_frame 里一次完成,层序和 macOS 导出端对齐:
缓存有三档预算:合成和效果纹理 256 MiB,C# 渲染图侧的随机访问 BGRA 缓存 64 MiB,倒放用的预取另有 288 MiB 的 NV12 平面缓存(1080p 下约 70 帧,60 fps 时略多于 1 秒)。倒放预取的异步工作线程默认是关的,因为实测更慢,只能用环境变量打开。代码里留着这种“做了但量出来不划算就关掉”的痕迹,比假装它有用诚实。
预览声音反而是网页做的:三个 <audio> 元素(系统声、麦克风、背景音乐)跟播放头同步,漂移超过 140 ms 就重新 seek。点击音效由宿主播放。
08导出:和预览同一张渲染图
导出和预览调用的是同一个 NativeCompositionRenderGraph.Create,一个把纹理 Present,一个把纹理交给编码器,有一道 NativePreviewExportParityGate 锁住这个约定。导出流程分 7 个阶段,以 IAsyncEnumerable 报进度:media → capability → plan → render → encode → audio → finalize。取消走 CancellationToken,没走完的路径都在 finally 里 Abort;临时文件有日志记录,崩溃后下次启动会清理。
参数上:VBR,GOP = 2 × fps,不用 B 帧,码率按“宽 × 高 × 帧率 × 每像素比特”算,H.264 乘 1.6、HEVC 乘 1.0。音频把系统声、麦克风、背景音乐、点击音混成一条 48 kHz 立体声 AAC(192 kbps),和视频在同一遍 SinkWriter 里写完;变速片段用 WSOLA 做时间伸缩,不变调。
导出可以转到后台:对话框把任务交给一个原生的悬浮条,任务渲染的是导出那一刻的 EditorDocument 不可变快照,所以导出期间继续编辑不会影响正在出的片子。
09去掉 FFmpeg
回头看,FFmpeg 的进出是这个项目最能说明问题的一条线。最早的 PR #1 用 FFmpeg gdigrab 抓桌面、libx264 软编码,产品流程能跑通,但违反了录制管线的每一条硬规则,只被当作参考。之后 WGC + Media Foundation 成了录制主路径,但 FFmpeg 仍然在包里干杂活:摄像头回退、ffprobe 读媒体信息、导出时的封装。
9 月瘦身时量过一次:FFmpeg 和 ffprobe 在压缩后的 MSIX 里占约 28 MB,整个 MSIX 54 MB。更大的问题不是体积,是隐藏的依赖:开发机的 PATH 上恰好有 FFmpeg 时,测试全绿也可能掩盖一个没声明的外部可执行文件依赖。10 月 16 日一天之内,导出改成 Media Foundation 单遍写音视频,摄像头不再依赖 FFmpeg,媒体信息改由 Media Foundation 读取,最后停止打包 FFmpeg,并加了一道守卫:scripts/assert-no-bundled-media-tools.ps1 在源码和最终包上都要通过,打包脚本自动跑。FFmpeg 现在只是开发和验收脚本从 PATH 里找的对照工具,规则里写明“验收必须在没有开发工具的 PATH 上,用实际出货的包跑”。
10截图
截图复用了大部分基础设施,但在启动延迟上下了功夫。流程是:从缓存里借一个已经初始化好的 WebView 选区窗口,同时冻结屏幕(WGC 抓一帧),选区页 selector.html 加载和冻结并行进行,等冻结的图片解码并绘制完才显示选区。验收把“请求到选区出现”和“确认到操作卡片出现”分开计时,规则里说得明白:包更小不算端到端延迟变短的证据。
两处具体的取舍:
- HDR 转换先走 Win2D 的像素着色器,着色器缺失、不支持或出错时自动回退到 CPU,CPU 路径用一张 65,536 项的 FP16 查找表。验收要求在全新进程里量 CPU 回退的耗时,并对照原始标量公式校验颜色。
- 图片送进 WebView用
PostSharedBufferToScript传无损 BMP,不走资源请求。4K 图实测 79–96 ms,对比资源请求回退的 248–309 ms。
11验收:证据而不是感觉
读这个仓库最有收获的一份文件是 AGENTS.md。它不是风格指南,是一条条“曾经被骗过”的验收规则。从名字就看得出,它是写给编码代理的完成标准,人也一样适用。几条有代表性的:
- 黄金向量、DOM 矩阵、数值几何是必要证据,但不充分。可见的渲染改动,要在同一时间戳把 macOS 参考帧和 Windows 帧整帧对比:录制画面、摄像头、边框、光标和点击、按键浮层、背景阴影,一项一项看。
- 没过完整清单之前,不许说 “1:1”“parity”“done”;有任何一项没验证,交接时要明说。
- 悬停效果要以 ≥60 fps 连续录制过渡,逐帧检查有没有一帧的颜色、透明度、模糊突变;静态的“正常态/悬停态”两张截图不算数。
- 滑杆要量内部对齐:滑块中心是否等于轨道中心,不能只看外框位置。
- 打包验收要检查进程退出码和 Windows 应用错误事件,“结果 JSON 写出来了”不代表干净退出。
- 商店符号验收要打开最终的
.msixupload,确认原生桥 PDB 的 GUID/age 和包里的 DLL 匹配,并且私有符号已剥离。 - 最后一条是元规则:评审里抓到的每一个漏网问题,都要变成新的清单项、测试或脚本,不靠记性。
工具链跟着这些规则长出来:CursorRasterGate 对点击效果做像素比对,EditorVisualGate 是一个 11,000 行的 DOM/几何闸门,NativeCompositionCache.Gate 拿 macOS 导出的超椭圆圆角 PNG 和原生蒙版的路径比对,UiResponsivenessBench 量 UI 线程上最长的单个工作项。测试没有用 xUnit,18 个测试项目跑在自己写的一个小 runner 上,由 scripts/test.ps1 统一驱动。
12回头看
把这五个月压成几条可以带走的经验:
- 共享格式,不共享代码。Swift 核心没法高质量地嵌进 Windows 应用,所以 C# 重写了一遍模型、reducer 和镜头规划,再用 macOS 导出的黄金数据锁住行为。跨平台靠的是
.cam和测试数据,不是一个公共库。 - 界面用 Web,像素和真相留在原生。WebView2 让编辑器界面的迭代速度接近前端项目,但 reducer 只在 C#,预览像素只来自 D3D11。网页只有草稿,没有决定权。
- 预览和导出必须是同一段代码。macOS 自己就因为预览用 CALayer、导出用 CIImage 而在画中画上有偏差,Windows 没有重复这个分叉。代价是子 HWND 的空域问题,以及围绕它的一整套对话框、弹层和指针路由设计。
- 系统媒体栈够用,但要验证。Media Foundation 替掉了 FFmpeg,包更小,依赖更清楚;但也要自己修 NVIDIA 写错的颜色标签、自己做硬件编码探测和回退。
- 验收标准写成文件。一个快速迭代、大量借助代理写代码的项目,最容易出的问题是“测试过了,但用起来不对”。把每次被骗的经过写进
AGENTS.md,再长成闸门工具,比任何一次性的代码评审都更持久。