Skip to content
liudon@localhost:~/blog$ cd /posts/safari-offscreen-video-black

Mac 跨桌面录屏,为什么 Safari 视频会黑屏

# 从 Safari 跨桌面录屏的视频黑屏现象出发,通过本地网页、OBS 对照、四种 Canvas 实验及 WebKit 源码,逐步定位可见性如何影响视频呈现。

author: Liudon date: 2026-11-13 words: 8666 read_time: 18m
$ less index.md

在 Gecko 的应用兼容性测试中,Safari 出现了一个问题:窗口在当前桌面时录制正常,切到另一个 Space,录像中的视频区域却变成了黑色。声音还在,录制也没有停止;切回原桌面,画面又恢复。

此前,QuickTime 的离屏冻结已经通过 Pulse 得到明显改善。Safari 这次消失的是整个视频内容,继续调整刷新频率并没有让它恢复。

录制的异常视频,在 00:08 到 00:20 秒为切离桌面期间,视频区域变为黑色。

问题出在 Gecko、ScreenCaptureKit,还是 Safari?为什么浏览器界面还能录到,只有视频部分消失了?

先检查采集链路:把视频放进本地网页

兼容性测试使用的是一个网络视频,存在一些不可控因素:播放器脚本、网络加载、跨域内容,以及可能的媒体保护。只看最终黑屏,很难知道是哪一段出了问题。

于是先构建一个本地网页,用 Safari 播放自己选择的本地视频,再录制这个窗口。视频不需要经过网站,也不需要下载或解密;选择录屏自动化测试设计里产生的带帧号编码的参考视频素材,让“原本就静止”与“没有采到内容”更容易区分。

页面由本地 HTTP 服务提供,通过文件选择器取得本地视频,再用 Blob URL 交给 <video> 播放。

视频仍由 Safari 播放,Gecko 仍从外部采集窗口。

测试页面负责什么

后面的实验都围绕这套本地测试页展开:保留相同的“视频输入、显示区域、阶段控制、数据记录”结构,再按要验证的问题调整显示方式或绘制开关。

Safari 本地视频测试页

实际测试页面,图中已选入视频但尚未开始记录。上方是视频选择和显示区,中间是播放与桌面切换标记,下方输出统计。

页面有四类功能:

  • 视频输入和显示:选择本地 MP4,在固定区域播放。
  • 阶段标记:开始播放、即将切离、返回、结束。按钮只记录操作意图,真正的页面隐藏和恢复由 visibilitychange 记录。
  • 源端观测:记录视频的播放与帧推进状态;具体指标在分析 Safari 状态时介绍。
  • 结果导出:将事件保存为 JSON,与外部录制的 MP4 对照。

基本操作始终是:先在当前桌面播放,切到不包含 Safari 的另一个桌面,等待,再切回并停止。

结果是:本地网页播放本地视频,同样会在离屏期间丢失视频画面。

下面是一个录制的结果视频,约 16–39 秒处于离屏稳定区间,视频内容缺失,返回后恢复:

同时用 OBS 做窗口录制对照,也出现了相同的离屏黑屏。这说明该现象并非 Gecko 独有;结合写入前的像素检查,排查重点应转向 Safari 的视频呈现与窗口采集之间。

黑屏发生在哪个阶段

接下来要区分文件写坏了,还是 SCK 返回的内容就有问题。

在原始视频场景中,对 SCK 回调取得的像素缓冲与交给 Writer 的拷贝后缓冲,使用相同的整窗及中央区域抽样。结果如下:

画面阶段SCK 整窗近黑比例Writer 整窗近黑比例SCK 中央区域近黑比例Writer 中央区域近黑比例
切离前25%25%29%29%
离屏中40%40%87%87%
返回后25%25%29%29%

27 组逐秒样本中,两端这些统计值全部相同。离屏期间 Writer 的已写入数也从 230 增加到 505,说明并非没有接收到样本。

中央区域近黑增加,在进入编码器之前已经出现,因此应优先把调查放在 Safari 呈现 → 窗口合成 → SCK 获取内容 这一段,而不是 MP4 封装或写入收尾。

本地素材也能复现,另一套录制实现也有相同现象,Gecko 文件写入之前就已缺少内容。接下来需要直接观察 Safari 在离屏期间做了什么。

画面黑了,视频是否还在播放

这时再展开页面中记录的几个指标。它们处于不同的观测位置,需要分别看:

指标记录的是什么用来回答什么
document.visibilityState浏览器判定页面为 visible 或 hidden页面实际何时进入隐藏、何时恢复
video.currentTime视频的当前播放位置播放时间轴是否继续推进
requestVideoFrameCallback(rVFC)视频帧相关回调浏览器是否仍在投递帧通知
回调中的 mediaTime这次回调对应视频帧的媒体时间帧所对应的内容位置是否前进
回调中的 presentedFrames累计提交给合成器的视频帧计数帧指标是否随播放增加

这一步只记录状态和回调,实际 hidden 区间中得到的数据是:

指标观测
页面 hidden 时长12.984 秒
保存的 rVFC 样本62 条
mediaTime7.300 → 19.967 秒
presentedFrames219 → 599

在这些样本的跨度内,帧指标推进速率约为 (599-219)/(19.967-7.300) ≈ 30 FPS。

这说明不能把现象简单解释成“切走以后视频就彻底停了”。但 rVFC 不是 WindowServer 或 SCK 的像素验收:浏览器的视频帧回调继续,并不保证原生视频层已经出现在窗口合成结果里。

下一个问题因此更具体了:Safari 是否仍能取得可用的视频像素,只是原生 video 的呈现路径没有进入外部录像?

用 Canvas 逐步拆开:取得视频像素与呈现视频

到这里,把源端日志和录像结果交给 Claude 分析时,它建议做一个对照:把同一段视频画到 Canvas,再录制这个 Canvas,看看离屏后是否还会黑屏。

这个方向的价值在于,播放源不变,只改变视频像素进入页面显示的路径。若 Canvas 可以被录到,而原生 video 不行,就能继续区分“取得视频帧”和“原生视频呈现”的差异。

1. 把视频画到可见 Canvas

先保留 video 作为播放源,把它放在 Canvas 下方并设为接近透明,让用户看到的是 Canvas 绘制的视频。

每次 rVFC 到达时执行:

function onFrame() {
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  video.requestVideoFrameCallback(onFrame);
}
video.requestVideoFrameCallback(onFrame);

两种路径的差别是:

原来的路径:video 播放 → 原生 video 呈现 → 窗口采集
新的路径:  video 播放 → drawImage → 可见 Canvas → 窗口采集

离屏后,Canvas 内的视频仍可以正常录下来。

页面同时定期对 Canvas 的 16×9 缩图计算 RGB 指纹,检查是否真的取得了不同像素。在实际 hidden 的 13.934 秒中,保存了 34 个指纹,34 个各不相同;最终录像中的 Canvas 也在变化。

因此,Safari 在这组条件下仍能提供视频像素,SCK 也能取得通过 Canvas 呈现的动态内容。但这一次同时改变了显示层和主动绘制行为,还不能判断哪个变化起了作用。

2. 保留原生 video,把 Canvas 藏起来

接下来让原生 video 正常显示,Canvas 改成 display:none。rVFC 和连续 drawImage 保持不变。

video.style.opacity = '1';
canvas.style.display = 'none';
// 仍然在每次视频帧回调中执行 drawImage(video)。

如果此前只是 Canvas 显示路径绕过了原生视频层的问题,那么隐藏 Canvas、重新显示 video 后,应该再次黑屏。

实测却是:离屏时,原生 video 也正常了。 实际 hidden 的 14.868 秒中,隐藏 Canvas 的 37 个指纹各不相同,成品中的原生视频持续播放。

由于 Canvas 不可见,录像里恢复的不是 Canvas 本身。这表明主动绘制可能影响了原生视频的状态。

但前面两个测试属于两个独立的会话,只能说持续绘制与避免黑屏有关,还不能把跨会话的差别当成严格因果关系。

3. 黑屏后,开启隐藏 Canvas 持续绘制

这次把干预放进同一次录制,保持同一个窗口、同一个可见 video 和同一个隐藏 Canvas。开始时完全不绘制,检测到页面 hidden 后等待约 7 秒,再开启连续绘制。

离屏期间不需要手动操作页面。隐藏事件只负责启动等待,真正绘制要等回调实际到达,并记录首个 draw 的执行时间,避免把“计划执行”误当成“已经执行”。

数据分成两个区间:

同一 hidden 区间持续时间绘制与像素记录成品观察
开启前7.054 秒0 次绘制,帧回调继续视频黑屏
开启后,到返回前14.130 秒持续绘制,34 个不同指纹视频恢复并持续运动

视频没有等待返回原桌面才恢复,而是在仍 hidden 时恢复了。这比“另一次录制正常”更有说服力:窗口和显示内容没有换,改变的是是否主动绘制视频。

现在还剩一个疑问:恢复需要持续刷新,还是一次操作就能改变后续状态?

4. 黑屏后,仅向隐藏 Canvas 绘制一次

持续绘制能让视频恢复,但恢复是否需要不断刷新?接下来把干预缩减为一次:页面离屏后先保持不绘制,等待黑屏出现,再向隐藏 Canvas 执行一次 drawImage(video),观察后续画面是否持续恢复。

只绘制一次,不读取像素。页面仍然显示原生 video,Canvas 仍然隐藏。

下面是这项干预的简化逻辑。等待从实际 hidden 开始,rVFC 是观测和执行机会,不把一次定时器唤醒当作绘制成功:

let hiddenSince = null;
let drawn = false;
let draws = 0;

document.addEventListener('visibilitychange', () => {
  if (document.hidden) hiddenSince = performance.now();
  else hiddenSince = null;
});

function onFrame() {
  if (!drawn && hiddenSince !== null
      && performance.now() - hiddenSince >= 7000) {
    ctx.drawImage(video, 0, 0, 960, 540);
    drawn = true;
    draws++;
    record({ kind: 'one_shot_draw_completed', draws });
  }
  video.requestVideoFrameCallback(onFrame);
}
video.requestVideoFrameCallback(onFrame);

单次离屏实验的核心逻辑示意。record 是页面的事件记录函数;这里没有像素读回,也没有第二次绘制。

实测数据如下,时间均相对于本次进入 hidden 的时刻:

事件或区间数据
首次也是唯一一次绘制hidden 后 7.004 秒
绘制后仍然 hidden12.497 秒
绘制前保存的帧回调样本33 条,累计绘制数为 0
绘制后保存的帧回调样本59 条,累计绘制数保持 1
绘制后的心跳13 条,累计绘制数保持 1
CPU 像素读回无
成品表现离屏先黑,单次绘制后持续恢复,期间没有返回桌面

单次隐藏 Canvas 绘制的执行记录

上轨是源端保存的帧指标,下轨是绘制次数。这是网页事件时间轴,不代表已经定位录像恢复的毫秒级边界。

为排除“离屏一段时间后本来就会自行恢复”,又交错比较了不绘制和单次绘制的情况。无干预的三个离屏区间都持续黑到返回,单次绘制的三个区间都在绘制后恢复。

四个实验逐步排除了不同解释:

干预结果推动了什么判断
可见 Canvas 显示视频视频像素可取得,Canvas 内容可进入窗口录像
隐藏 Canvas 持续绘制恢复的不只是 Canvas,原生 video 也受影响
同次离屏后开启持续绘制不必返回桌面,绘制能改变已发生的黑屏
离屏后仅单次绘制持续刷新不是必要条件,一次绘制后视频仍持续恢复

这时需要解释的已经不是“Canvas 为什么可以录屏”,而是:为什么一次不可见的绘制,会改变原生视频此后十余秒的表现?

从录制端截一次图,能否获得同样的效果

一次隐藏 Canvas 绘制就能恢复视频,带来了一个更直接的想法:既然 Gecko 无法直接操作 Safari 网页,能不能从采集端请求一次窗口截图,达到类似效果?

为此,使用 SCScreenshotManager.captureSampleBuffer 对准同一个 Safari 窗口,在视频已经离屏黑屏后请求截图。分别试了独立进程调用,以及放进 Gecko 的录制进程中调用。

调用方式截图请求离屏视频是否恢复
独立进程请求窗口截图成功取得像素缓冲没有,直到返回原 Space 才恢复
Gecko 录制进程内请求窗口截图成功取得像素缓冲没有,直到返回原 Space 才恢复

结果没有复现 Canvas 的效果。在这两种测试方式下,外部窗口截图不能替代网页内部的 drawImage(video)。 成功取得窗口缓冲,也不意味着浏览器会重新呈现其中的视频。

既然一次网页绘制有效,而一次外部截图无效,需要继续追查的就是:drawImage(video) 除了取得当前帧,还改变了什么状态?

源码线索:页面隐藏与媒体隐藏不是同一件事

要解释这些现象,只能从源码里来找了。

本机 Safari 构建号为 21624.5.1.11.3,匹配公开发布标签 WebKit-7624.5.1.11.3,固定提交为 26695efdefc6。下文涉及现场实现的代码均来自这个发布提交。

1. Canvas 绘制可以改变播放器状态

HTMLVideoElement::paintCurrentFrameInContext 在取得播放器后执行:

if (!player->isVisibleForCanvas()) {
    player->setVisibleForCanvas(true);
    visibilityStateChanged();
}
player->paintCurrentFrameInContext(context, destRect);

这段代码先设置 visibleForCanvas,再重新计算媒体可见性,最后才绘制当前视频帧。

该函数没有在绘制完成后立即把标志恢复。因此一次绘制可能产生超出本次 draw 调用时长的影响,这与“只画一次,后续原生视频仍恢复”的实验相符。

2. document hidden 不必等于媒体 hidden

HTMLMediaElement::elementIsHidden 排除全屏等分支后,普通视频使用这个判断:

return protectedDocument()->hidden()
    && (!m_player || !m_player->isVisibleForCanvas());

对已经有播放器的普通视频,可以把关系展开:

document hiddenvisibleForCanvas媒体 hidden
falsefalsefalse
truefalsetrue
truetruefalse

由此可见,页面 hidden 与媒体 hidden 可以不同:为 Canvas 保持可见的媒体,不再仅因为 document 隐藏就被判为 hidden。

3. 媒体结果继续传给播放器

同文件的 visibilityStateChanged() 保存新的 m_elementIsHidden,发生变化时调用:

player->setPageIsVisible(!m_elementIsHidden);

这个函数名容易造成误解:传下去的值并非始终等于网页 document.visibilityState。这里传递的是经过媒体元素规则计算后的结果。

接着,状态通过远程媒体播放器与 GPU 代理传入 AVFoundation 后端。源码给出了传播路径,但它是不是实际发生在 Safari 中,还需要后面的运行日志验证。

4. 后端隐藏视频层

匹配发布版本的 MediaPlayerPrivateAVFoundation::setPageIsVisible 保存可见性,恢复可见时准备视频渲染,再调用平台实现。

platformSetVisible 在视频层存在时,用禁用隐式动画的事务执行:

[m_videoLayer setHidden:!isVisible];

于是得到一个可以用实验验证的解释:窗口离屏后,页面进入隐藏;普通媒体随之变为不可见,后端隐藏视频层。单次 Canvas 绘制则可能通过 visibleForCanvas 改变媒体判断,让 document 隐藏时媒体仍保持可见。

而 Canvas 干预是在媒体判断处切开这条链:document 仍然 hidden,但媒体保持可见。

这解释了“单次操作的持续影响”,也带来两个新预测:

  1. 如果绘制改变的是状态,那么切离前执行一次,也应该可能有效,无须等黑屏发生。
  2. 如果在更上游阻止窗口遮挡变成页面隐藏,即使不绘制 Canvas,也可能保持视频呈现。

下面分别检验这两个预测。

验证第一个预测:切离前画一次,能否预防黑屏

如果单次绘制改变的是播放器可见性,那么无需等到页面隐藏才执行。先在前台画一次,随后切离,也可能避免黑屏。

这个预测与“黑屏以后强制刷新一下”不同,因此值得单独做 A/B 对照测试。

实验怎么控制

在本地测试页中比较两种条件:不执行绘制,以及切离前绘制一次。两边都执行相同的前台准备操作,唯一需要改变的是是否真正执行绘制。

绘制门控检查页面为 visible、视频已有当前帧、正在播放且没有结束;记录尝试数、成功数、错误和完成时刻。门控执行后关闭,不重试、不补画。

页面仍保留 rVFC 和心跳,但这些只负责记录,不承担绘制触发。

观测项不绘制切离前单次绘制
Canvas 成功绘制次数01
像素读回无无
页面实际隐藏时长27.476 秒28.284 秒
绘制与隐藏的关系无绘制完成后约 439 ms 进入隐藏
录像中的原生视频离屏空白离屏保持更新

单次绘制的原始录像如下。约 11–35 秒是本文抽样分析的稳定区间,能看到条码和运动色块继续变化:

不能让页面计数器替视频证明更新

页面上的计数器仍在变化,不能替视频证明画面正常。这里只裁取两段录像中视频内部的同一矩形,每秒取两张并缩为灰度图,避开浏览器 UI、网页按钮和计数器:

检查A:不绘制B:单次绘制
MP4 分析区间16–39 秒11–35 秒
抽样图像数4648
不同图像数148
每张图的亮像素数,灰度 >18001232–1610

A 的 46 个样本完全相同;B 的 48 个样本各不相同。

更重要的是,执行绘制的页面仍然隐藏了 28 秒。单次绘制没有把页面搬回前台,却保住了视频呈现。

这使“页面可见性”和“媒体可见性”分离的假设,有了直接可观察的实验支持。

验证第二个预测:阻止遮挡状态变成页面隐藏

Canvas 干预改变的是媒体判断,还要继续查 document 为什么进入 hidden。

沿 WebKit 的窗口可见性实现查到:macOS 窗口的 occlusion 状态会参与 WebView 是否可见的判断。源码中的 windowOcclusionDetectionEnabled 默认开启,对应的私有方法是:

_setWindowOcclusionDetectionEnabled:

这个开关为第二个预测提供了直接的干预位置:保留窗口离屏,只让 WebKit 不再根据遮挡把页面判为不可见。

Gecko 无法直接拿到 Safari 内部的 WKWebView 对象。因此写了一个最小 WKWebView 宿主,加载同一套本地视频测试页,再由 Gecko 从外部录制。测试目标换成可控制的 WebKit 宿主,视频输入、页面观测和外部窗口录制方式继续沿用。

开关定义可见于对应发布源码的 WebViewImpl.h 和 WKWebViewMac.mm。

两轮使用同一宿主程序,分别用全新进程启动。页面均不执行 Canvas 绘制。

需要同时记录三种状态

最小宿主不只记录网页的 visibilityState,还记录:

window.occlusionState.contains(.visible)
window.isVisible
window.isMiniaturized

这三个值解决不同的问题。isVisible=true 不代表窗口当前在用户眼前;离开 Space 后,窗口可以仍处于显示状态、没有最小化,但 occlusion 的 visible 位已经消失。

宿主还把页面心跳、播放事件和可见性消息写入同一份原生日志。结合原生窗口日志,就能确认窗口是否确实离屏,以及离屏后页面是否仍保持 visible。

在自建宿主中,实际写入开关的关键代码如下。这里使用 ObjCBool 明确方法的参数约定,并通过 getter 读回,不使用把布尔值当对象传入的 perform(_:with:):

let getter = NSSelectorFromString("_windowOcclusionDetectionEnabled")
let setter = NSSelectorFromString("_setWindowOcclusionDetectionEnabled:")
guard webView.responds(to: getter), webView.responds(to: setter) else {
    fatalError("当前 WebKit 不支持诊断开关")
}
typealias Read = @convention(c) (AnyObject, Selector) -> ObjCBool
typealias Write = @convention(c) (AnyObject, Selector, ObjCBool) -> Void
let read = unsafeBitCast(webView.method(for: getter), to: Read.self)
let write = unsafeBitCast(webView.method(for: setter), to: Write.self)
write(webView, setter, false)
precondition(!read(webView, getter).boolValue)

仅用于自己的 WKWebView 诊断宿主。它是目标进程内的私有 API,不能用来设置另一个进程里的 Safari。

结果:窗口遮挡没有消失,页面隐藏消失了

条件系统窗口状态网页状态录像内容
A:默认检测遮挡约 28 秒,未最小化hidden 28.037 秒视频空白
B:关闭检测遮挡 38.082 秒,未最小化播放期间保持 visible视频更新

WKWebView 默认检测:约 16–39 秒的视频内部区域在抽样中保持空白。

WKWebView 关闭检测:约 12–45 秒的视频内部区域持续变化。原生窗口日志确认离屏,不靠页面 hidden 来判断是否切走。

同样只分析视频内部区域:A 的 46 个样本只有 1 种图像;B 的 66 个样本有 66 种不同图像。

这个实验说明:在该 WKWebView 配置下,把窗口遮挡转换成页面隐藏,是导致视频内容缺失的重要上游因果因素。 关闭这一转换,系统仍认为窗口被遮挡,录像却能更新。

它还不能独立证明 Safari 的最终后端动作。Safari 需要自己的运行态证据。

回到实际 Safari:不可见状态是否真的传到了后端

两个预测的结果都符合源码解释。接下来回到 Safari 本身,检查之前两段录像期间的 unified log:页面不可见是否真的变成了同一播放器的媒体不可见,并传到 GPU 后端?

WebKit 是多进程架构,媒体状态可能经过 WebContent 和 GPU 进程。按时间搜索 false 远远不够:同一时刻可能存在其他窗口、旧页面或其他播放器。

本次关联使用 Safari/WebContent/GPU 的进程归属、页面标识,以及每轮播放器的 identifier。A/B 重载后播放器标识发生变化,必须分别跟踪。

不执行绘制的 Safari

以视图变为不可见作为零点,真实日志中的消息可以整理为:

+0 ms   Safari / ViewState:
          view visibility state changed 1 -> 0
+1 ms   WebContent / HTMLMediaElement:
          visibilityStateChanged(ELEMENT_A) visible = 0
+1 ms   WebContent / MediaPlayerPrivateRemote:
          setPageIsVisible(PLAYER_A) false
+1 ms   GPU / RemoteMediaPlayerProxy:
          setPageIsVisible(PLAYER_A) false
+1 ms   GPU / MediaPlayerPrivateAVFoundationObjC:
          setPageIsVisible(PLAYER_A) false

摘录保留消息顺序及日志时间差,省略线程和地址,将长标识替换为 ELEMENT_A、PLAYER_A。毫秒级日志粒度不能当成精确的跨进程执行耗时。

返回时,同一播放器的媒体可见性和后端状态恢复 true,录像画面也恢复。

页面记录与系统日志存在约 40 ms 的观察差异,但覆盖的是同一个离屏区间。分析时应按播放器标识关联,不应只搜索整个 GPU 进程里有没有 false:其他页面的播放器也可能正发生可见性变化。

单次绘制的 Safari

另一个对照里,单次 Canvas 绘制先发生,Safari 视图随后仍然进入不可见状态;录像中的视频保持更新。对应离屏区间内,没有观察到该播放器转为媒体不可见或后端收到 false 的消息。

“没有日志”不能独立证明函数绝对没有调用。但它与页面仍 hidden、单次绘制、最终画面更新互相支持:Canvas 干预没有阻止视图隐藏,却改变了媒体可见性传播的结果。

从源码到本机二进制:验证视频层的隐藏逻辑

公开源码的版本号匹配,比直接看 main 可靠,但仍不等于与 Apple 签发二进制逐指令一致。

因此又做了一次只读核对:在自建临时进程中加载系统安装的 WebCore,用 LLDB 检查 MediaPlayerPrivateAVFoundationObjC::platformSetVisible(bool)。

实际加载位置在 WebKit.framework 内部的 WebCore,镜像来自 dyld 共享缓存。

关键反汇编如下,省略运行时绝对地址:

ldr x8, [x0, #0x1a8]
cbz x8, ...                     // 没有视频层则返回
...
mov x19, x1                     // 保存可见性参数
...
ldr x0, [x20, #0x1a8]            // 视频层对象
eor w2, w19, #0x1                // 对布尔值取反
bl objc_msgSend$setHidden:        // 设置视频层 hidden

这确认了安装版关键函数确实仍然是按可见性设置 hidden,与匹配发布源码一致。

整条证据链现在包含了四种不同性质的证据:

证据能回答的问题
原始录像与视频区域抽样内容是否真的缺失,干预后是否真的更新
Canvas / WKWebView 两种干预改变媒体状态或上游遮挡处理,能否改变结果
实际 Safari 跨进程日志不可见状态是否传到现场播放器后端
发布源码与安装版机器码后端关键函数按什么规则处理视频层

实验验证了可见性干预会改变录制结果,运行日志确认不可见状态传到了媒体后端,匹配源码和本机机器码则确认后端按该状态隐藏视频层。这些证据共同确定了本次黑屏的机制:Safari 的可见性策略抑制了视频层呈现。

结论:Safari 的可见性策略隐藏了离屏视频层

本次已测的非 DRM 视频场景中,离屏黑屏的原因是:窗口切到其他 Space 后被判为遮挡,WebKit 将页面设为隐藏;普通 video 随之被判为媒体不可见,AVFoundation 后端隐藏了视频层,最终使外部窗口录制缺少视频内容。

这条链路可以用下图概括:

Safari 离屏黑屏与返回恢复的交互过程

根据实验、实际 Safari 日志、匹配发布源码和安装版实现整理的机制图。箭头表示状态传播和内容变化,不表示精确的调用耗时;视频层动作来自代码与机器码核对,并非现场属性快照。

SCK 回调和文件写入仍可以持续,所以帧数增加、文件正常保存,并不能保证视频区域仍有内容。返回原 Space 后,可见性恢复,后端重新显示视频层,录像中的视频也随之恢复。

单次 Canvas 绘制与关闭 WKWebView 遮挡检测,则分别改变了媒体可见性和页面可见性,使这条隐藏链路不再按原路径继续。这也是它们能够改变录制结果的原因。

以上结论对应本文核对的 Safari/WebKit 版本与本地非 DRM 素材,不延伸为所有 Safari 视频来源的通用判断。

Gecko 能控制到哪一步

找到有效干预,并不意味着 Gecko 能对任意 Safari 窗口执行它。问题在于,这些控制点都位于被录制的应用内部。

网页中的 drawImage(video) 需要页面或浏览器配合,不能通过 SCK 的窗口 ID 直接执行。即使获得了这种配合,现有实验也只覆盖了已测素材与几十秒的隐藏区间;换源、seek、重建播放器和长时录制,还需要另外验证。

关闭遮挡检测的私有开关,则属于目标进程里的 WKWebView 对象。自建宿主可以调用它,Gecko 无法用同样的方式修改另一个进程中的 Safari 窗口。

Pulse 能改善离屏供帧,但不能修改 Safari 内部的媒体可见性。前面的外部截图实验也没有恢复视频。因此,原因虽然已经定位,仍未找到无需 Safari 配合、可由 Gecko 直接启用的通用修复入口。

相关复现材料已经提交到 WebKit #331092,并补充到 Apple Feedback FB25368989。

线索来自另一个浏览器

Safari 的排查一度停在“为什么单次 Canvas 绘制就能恢复视频”这里。后来,Gecko 基础功能完成,回归测试时却发现 Chrome 也出现了离屏黑屏——而早期测试明明是正常的。一条从终端 history 中翻出来的 Chrome 启动命令,最终让排查转向了窗口遮挡策略,也为 Safari 的源码分析提供了方向。

这是 Gecko 开发过程中投入时间最多、最难推进的一个问题。多次以为找到了突破口,沿着线索查下去,却又停在了新的边界。我查阅的公开资料中,没有找到能完整解释这一现象的分析,因此把这次从现象、对照实验到源码验证的过程整理下来,希望能给遇到类似问题的人留下一条可参考的路径。

下一篇 Chrome 篇,讲讲 Chrome 前后矛盾的表现:为什么早期能正常离屏录制,后来也出现了黑屏现象?

$ sponsor --coffee# 如果觉得有用,欢迎请我喝杯咖啡
$ twikoo --page /posts/safari-offscreen-video-black/
loading comments
NORMAL index.md words: 8666 EOF