Skip to content

录屏 ​

录屏是 Nova 的「眼睛」:游戏跑着的时候把画面从显卡缓冲区里取出来,硬件编码存成本地 mp4。

选型:WGC 取帧 + MF 硬件编码 ​

取帧用 Windows.Graphics.Capture(WGC)。 它拿到的帧是 DWM 直接共享给本进程的 D3D11 纹理——画面从未离开显卡缓冲区:没有 BitBlt / PrintWindow 那种「把屏幕拷回 CPU」的路径,也不受窗口遮挡影响——录的是那个窗口本身,不是「那个窗口在屏幕上的位置」。

编码用 Media Foundation 的 SinkWriter,并打开硬件变换:

csharp
MediaFoundation.SetUInt32(attrs, MfGuids.MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, 1);
MediaFoundation.SetUInt32(attrs, MfGuids.MF_SINK_WRITER_DISABLE_THROTTLING, 1);
MediaFoundation.SetUInt32(attrs, MfGuids.MF_LOW_LATENCY, 1);

驱动注册的 H.264 MFT(NVENC / Quick Sync / AMF)会被优先选中,拿不到时 MF 自动退回软编。启动器启动时探测一次本机编码器并写进日志——出问题时一眼能看出是软编还是硬编。输出格式固定 H.264,默认 30fps / 12 Mbps。MF 没有 .NET 投影,所以 MfInterop.cs 手写了用到的那一小部分。

核心设计:不用 FrameArrived ​

这是整套录屏里最反直觉的一点。

WGC 的 FrameArrived 事件是按画面变化推帧的:屏幕上什么都没动,就收不到回调。如果按事件驱动来写码,静止的桌面会让视频时长停住——录了 30 秒的视频可能只有 2 秒的时间轴。

所以编码线程改用 Stopwatch 节拍循环:

csharp
var clock = Stopwatch.StartNew();
var next = 0L;
while (!ct.IsCancellationRequested)
{
    var due = next - clock.ElapsedTicks * 10_000_000L / Stopwatch.Frequency;
    if (due > 0) { await Task.Delay(...); continue; }

    GrabLatestFrame();                       // 到点了就去取
    if (_latest is not null) WriteFrame(_latest, next);   // 拿不到新帧就重复上一帧
    next += _frameDurationHns;               // 时间戳始终按真实时长推进
}

关键在于时间轴与真实时长一致:到点抓帧,抓不到新帧就把上一帧再写一遍(画面静止时内容相同,但时间戳继续走)。用户看到的视频时长永远等于实际录制时长。帧池是 CreateFreeThreaded 的,所以 TryGetNextFrame 不需要在回调线程上。

两个入口 ​

RecordingService 提供两条路径:

  • 游戏启动后自动录:等游戏窗口出现再录那个窗口(BeginAutoRecording → WaitForGameWindowAsync)。用 WGC 抓窗口不受遮挡影响,桌面上的别的东西不会被录进去。等窗口的上限是 30 秒(WindowWait)——Java 进程起来到窗口映射通常几秒,给足余量但不无限等;超时写一行日志,这一局不录。多开时按 pid 认实例。
  • 手动录屏:录主显示器(CaptureItemInterop.PrimaryMonitor())。

两个入口都只在「录屏许可」开启时可用:

csharp
if (!_permissions.IsOn(NovaPermissionStore.Recording))
{
    message = Loc.T("录屏许可还没开:先在上面的开关里授权,Nova 才会看屏幕。");
    return false;
}

每次开录 / 停录都写一条可审计记录(NovaActionLog),落盘目录是 %APPDATA%/starcloud-launcher/recordings。

坑一:COM 接口继承的 vtable 顺序 ​

MfInterop.cs 里 IMFMediaType 和 IMFSample 把 IMFAttributes 的 30 个方法显式抄了一遍,而不是写成 : IMFAttributes:

csharp
// ⚠ 下面两个接口把 IMFAttributes 的 30 个方法**抄了一遍**,而不是写成 `: IMFAttributes`。
//
// 原因(踩过一次才知道):COM 的派生接口 vtable 里基接口方法排在前面,但 C# 的 ComImport
// 接口继承在这台机器上没有按这个规则排——结果 IMFSample.AddBuffer 落到了
// IMFAttributes.GetString 的槽位上,报 MF_E_ATTRIBUTENOTFOUND (0xC00D36E6),
// 报错点看起来像"加缓冲区失败",实际是槽位错位。

这个 bug 的迷惑性在于报错点完全指错了方向:sample.AddBuffer(buffer) 抛 MF_E_ATTRIBUTENOTFOUND,看起来像「加缓冲区失败」,实际是那个调用被绑到了 GetString 槽位上,去查属性了。显式展开后,无论运行时是否自动展开基接口方法,槽位都一致。

代价是 SetGuid / SetUInt32 这些 helper 的入参收 object(因为 IMFMediaType 不再继承 IMFAttributes),需要显式 QI 一次。另外注意 MFCreateSinkWriterFromURL 住在 mfreadwrite.dll,不在 mfplat.dll——按 DLL 找错了会 EntryPointNotFound。

坑二:宽高必须取偶数 ​

csharp
// 宽高都取偶数:H.264 的 4:2:0 色度是 2×2 下采样,奇数边会被编码器拒绝
_width = Math.Max(2, size.Width & ~1);
_height = Math.Max(2, size.Height & ~1);

H.264 的 4:2:0 色度是 2×2 下采样,编码器会直接拒绝奇数边。这条不写在注释里根本猜不到。

还有一条:输入是自上而下的一整行像素,要显式给正 stride,避免 MF 按 DIB 习惯当成倒过来的图(MF_MT_DEFAULT_STRIDE)。

其它小坑 ​

  • 光标入画只在 Windows 19041+ 支持,项目最低支持 17763,所以显式判版本而不是靠 try/catch 吞异常。
  • IsBorderRequired(关掉系统黄框)是 21H2+ 的属性,本项目 SDK 投影(19041)里没有,不为它抬 TargetPlatformVersion——黄框是系统的「正在被捕获」提示,留着也不算坏。
  • MF_MT_FRAME_SIZE / FRAME_RATE / PIXEL_ASPECT_RATIO 都是 HI32:LO32 打包的 UINT64,写的时候要用 SetPackedPair。
  • 停止时要 FinishWriting()——写完 moov 才算可播放,少了这一步文件播不了。

相关链接:Nova 权限模型 · Nova 工具集

基于 MIT 许可证(有修改)开源