{"version":"https://jsonfeed.org/version/1.1","title":"流动","home_page_url":"https://liudon.com/en/","feed_url":"https://liudon.com/en/index.json","authors":[{"name":"Liudon"}],"items":[{"title":"Why Chrome Videos Go Black in ScreenCaptureKit Recordings When Switching macOS Spaces","id":"https://liudon.com/en/posts/chrome-offscreen-video-black/","url":"https://liudon.com/en/posts/chrome-offscreen-video-black/","summary":"Why did Chrome window recording work across macOS Spaces, then start showing black video? A forgotten launch flag, Pulse comparisons, Chromium source, and a media trace reveal suppressed video submission while decoding continues.","content_html":"\u003cp\u003eIn \u003ca href=\"/en/posts/safari-offscreen-video-black/\"\u003ethe Safari article\u003c/a\u003e, I mentioned a clue that came from Chrome. To explain it, I need to return to Gecko\u0026rsquo;s early tests of recording across macOS Spaces.\u003c/p\u003e\n\u003cp\u003eBack then, switching away from Chrome\u0026rsquo;s Space mainly caused sparse picture updates. After introducing \u003ca href=\"/posts/gecko-adaptive-pulse-design/\"\u003ePulse\u003c/a\u003e (in Chinese), I could capture continuously changing video frames.\u003c/p\u003e\n\u003cp\u003eLater, compatibility testing revealed black video in Safari while offscreen. Remembering that Chrome had worked, I returned to Chrome for comparison—and found that it now turned black too.\u003c/p\u003e\n\u003cp\u003eRecording continued, audio continued, and returning to the original desktop restored the picture. Yet I could no longer reproduce the earlier result of continuous updates with Pulse enabled.\u003c/p\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/chrome-offscreen-video-black/chrome-offscreen-video-black.mp4.poster.webp\"\n        width=\"640\"\n        height=\"364\"\n        style=\"--video-ratio: 640 / 364\"\n    \u003e\n        \u003csource\n            src=\"/posts/chrome-offscreen-video-black/chrome-offscreen-video-black.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003cp\u003e\u003cem\u003eThe recorded symptom: from 00:08 to 00:24, I am on another Space and the video region is black. The original recording retains its original interface language.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eWhy was the same browser behaving differently? Had I broken capture, had Pulse stopped working, or had I missed a test condition?\u003c/p\u003e\n\u003ch2 id=\"trying-safaris-canvas-workaround-in-chrome\"\u003eTrying Safari’s Canvas workaround in Chrome\u003c/h2\u003e\n\u003cp\u003eInitially, I kept investigating the capture side: whether the transparent window was actually presenting, whether the refresh frequency matched, and whether capture settings had changed. But even with Pulse confirmed to be working, the video did not recover.\u003c/p\u003e\n\u003cp\u003eIn Safari, drawing video into a hidden Canvas once restored the offscreen native video. I tried the same idea in Chrome, hoping for a common way forward.\u003c/p\u003e\n\u003cp\u003eReusing the Safari page immediately exposed another problem: Chrome stopped delivering \u003ccode\u003erequestVideoFrameCallback\u003c/code\u003e (rVFC) callbacks while offscreen, so the draw scheduled inside that callback never executed. That established only that the intervention had not run, not whether Canvas could help.\u003c/p\u003e\n\u003cp\u003eI changed the trigger to a timer heartbeat. About seven seconds after the page became hidden, it attempted one \u003ccode\u003edrawImage(video)\u003c/code\u003e into the hidden Canvas; rVFC remained an observation only. The completion event confirmed that this time the operation ran:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eObservation\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eResult\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSingle draw completed\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eAbout 7.332 seconds after page hidden\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDraw count and errors\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOne draw, no drawing error, no pixel readback\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eTime still hidden after drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eAbout 19.466 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eNative video in the recording\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eRemained black until returning to the original desktop\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThis ruled out failure to execute the draw, but it did not reproduce Safari\u0026rsquo;s recovery.\u003c/p\u003e\n\u003cp\u003ePulse did not restore the content, the Canvas intervention that worked in Safari did not help, and the earlier successful tests were still there. Development had stretched on, and I could not explain the contradiction. I wondered whether something had broken during development and more than once considered giving up.\u003c/p\u003e\n\u003ch2 id=\"the-missing-clue-in-shell-history\"\u003eThe missing clue in shell history\u003c/h2\u003e\n\u003cp\u003eThe investigation stalled there. Then, in early November, an incidental look through terminal history turned up an unusual Chrome launch command with an extra flag:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eopen -a \u0026#34;Google Chrome\u0026#34; --args --disable-backgrounding-occluded-windows\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePerhaps Chrome had been launched with this flag early on, and subsequent proof-of-concept (POC) tests had kept reusing that running instance. After restarting the computer or browser, a normal launch would omit the flag, potentially changing the results.\u003c/p\u003e\n\u003cp\u003eIt was still a hypothesis. To see whether the flag really affected the outcome, I needed comparisons with and without it.\u003c/p\u003e\n\u003cp\u003eA manual comparison using Gecko was striking: \u003cstrong\u003ewith the flag, offscreen video recorded normally; with a normal launch, the video region went black.\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"reproducing-the-earlier-result-with-the-original-poc\"\u003eReproducing the earlier result with the original POC\u003c/h2\u003e\n\u003cp\u003eThe manual Gecko comparison showed that Chrome\u0026rsquo;s launch flag affected offscreen recording. But could it explain why the early POC had worked and later become impossible to reproduce?\u003c/p\u003e\n\u003cp\u003eI returned to the original minimal capture program and reran the same Pulse comparison, once with Chrome launched normally and once with the flag:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ePulse OFF → Pulse ON (30 Hz) → Pulse OFF again\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIf the launch flag was the key condition, adding it should restore the earlier pattern: continuous updates with Pulse on, sparse updates again with Pulse off.\u003c/p\u003e\n\u003cp\u003eBoth runs used the same capture and Pulse implementation and the same frame-number video; Chrome\u0026rsquo;s launch mode changed. The Chrome version was \u003cstrong\u003e156.0.8078.81\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eThe footage came from the reference video used in my \u003ca href=\"/posts/gecko-recording-test-automation/\"\u003erecording automation work\u003c/a\u003e (in Chinese). Each frame contained a decodable source-frame ID, allowing me to measure which video content arrived rather than just counting callbacks.\u003c/p\u003e\n\u003cp\u003ePulse was a transparent Metal window, 1×1 logical point, in the capture process. While on, it redrew the same pixels at about 30 Hz; while off, it did not continuously redraw. The captured target was on another Space, in macOS native fullscreen mode.\u003c/p\u003e\n\u003ch3 id=\"1-normal-launch-pulse-presented-but-content-did-not-recover\"\u003e1. Normal launch: Pulse presented, but content did not recover\u003c/h3\u003e\n\u003cp\u003eFirst, Chrome launched without the flag. Excluding five seconds at each end of each phase gave:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003ePhase\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eStable interval\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eSCK complete\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eSCK idle\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eValid source-frame IDs\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse off\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e50.041 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e2906\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse on, 30 Hz\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e50.107 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e2880\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse off again\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e49.895 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e2899\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe Pulse log recorded 1803 valid presentations at about 30 Hz. \u003cstrong\u003ePulse ran as expected, but capture still did not recover usable video content.\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"2-launching-with-the-flag-sparse-continuous-then-sparse-again\"\u003e2. Launching with the flag: sparse, continuous, then sparse again\u003c/h3\u003e\n\u003cp\u003eWith the flag, I repeated the same three phases:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003ePhase\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eStable interval\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eDistinct source-frame IDs per second\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eContent behavior\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse off\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e50.037 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0.060\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOnly three valid frame IDs\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse on, 30 Hz\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e50.466 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e\u003cstrong\u003e30.001\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e1514 consecutive frame IDs\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePulse off again\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e49.541 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e2.705\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eSparse updates with long gaps\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe middle phase decoded source-frame IDs from \u003cstrong\u003e3183 through 4696\u003c/strong\u003e, with every adjacent difference equal to one: no repetitions or regressions. Continuous video content had returned; this was not merely SCK delivering more copies of the same picture.\u003c/p\u003e\n\u003cp\u003eThe final phase averaged about 2.7 distinct frame IDs per second, but included an approximately \u003cstrong\u003e21-second\u003c/strong\u003e gap between updates. Calling that average stable low-frame-rate playback would be misleading.\u003c/p\u003e\n\u003cp\u003ePlotting the valid frame IDs from both original logs makes the distinction clearer:\u003c/p\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_320x.avif 320w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_480x.avif 480w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_720x.avif 720w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_320x.webp 320w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_480x.webp 480w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_720x.webp 720w, /posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/chrome-offscreen-video-black/chrome-pulse-framecode-en.png\" alt=\"Effect of Chrome’s launch flag and Pulse on captured source-frame progression\"\n         width=\"1080\" height=\"665\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eEach point is a successfully decoded source-frame ID. Missing updates remain gaps rather than being joined by lines. The chart shows complete phases; the tables exclude five seconds at each end of each phase.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eThe comparison reproduced the earlier pattern: with the flag, updates became continuous when Pulse was enabled and sparse again when disabled. Under the default launch configuration, Pulse did not restore the content.\u003c/p\u003e\n\u003cp\u003eThe POC also showed that, in this configuration, the flag alone did not ensure continuous recording: \u003cstrong\u003eupdates stayed sparse with the flag but Pulse off; the flag and Pulse together restored consecutive source frames.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThat made the history command a strong explanation for the difference. The next question was what the flag actually changed—and why that affected whether Pulse could restore the picture.\u003c/p\u003e\n\u003ch2 id=\"what-does-the-flag-change\"\u003eWhat does the flag change?\u003c/h2\u003e\n\u003cp\u003eThe word \u003ccode\u003eoccluded\u003c/code\u003e became the important clue: it pointed to window occlusion, rather than a video-decoder option.\u003c/p\u003e\n\u003cp\u003eIn \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/156.0.8078.81/content/browser/web_contents/web_contents_impl.cc\"\u003eWebContentsImpl\u003c/a\u003e for the matching Chrome 156 release, \u003ccode\u003eUpdateWebContentsVisibility\u003c/code\u003e checks this flag. The key handling can be reduced to:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eocclusion_is_disabled\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003evisibility\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"n\"\u003eVisibility\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003eOCCLUDED\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003evisibility\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eVisibility\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003eVISIBLE\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eHere, \u003ccode\u003eVISIBLE\u003c/code\u003e is the visibility state Chrome passes to subsequent logic. It does not mean macOS has actually moved the window back to the current desktop.\u003c/p\u003e\n\u003cp\u003eThe window stays offscreen, but Chrome no longer handles it with the same occlusion state. That explains how a launch flag can change recording across Spaces.\u003c/p\u003e\n\u003cp\u003eIt does not disable every background policy, either. This code specifically handles \u003ccode\u003eOCCLUDED\u003c/code\u003e; switching Spaces, minimizing a window, hiding the app, and switching browser tabs should not all be treated as the same operation.\u003c/p\u003e\n\u003cp\u003eI now needed to follow the state downstream: \u003cstrong\u003eonce the page becomes invisible, where does the video pipeline stop?\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"from-page-invisibility-to-the-video-submission-condition\"\u003eFrom page invisibility to the video submission condition\u003c/h2\u003e\n\u003cp\u003eThe matching source connects the following path:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eWebMediaPlayerImpl::OnPageHidden\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    ↓ Set page visibility\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eVideoFrameCompositor::SetIsPageVisible\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    ↓ Forward to the video submitter\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eVideoFrameSubmitter::SetIsPageVisible\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    ↓ Store the state and recalculate submission\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eUpdateSubmissionState\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/156.0.8078.81/third_party/blink/renderer/platform/media/web_media_player_impl.cc\"\u003eWebMediaPlayerImpl\u003c/a\u003e handles the hidden page and notifies \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/156.0.8078.81/third_party/blink/renderer/platform/media/video_frame_compositor.cc\"\u003eVideoFrameCompositor\u003c/a\u003e across threads. The compositor forwards the state to \u003ccode\u003eVideoFrameSubmitter\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThe submitter delivers video frames to the subsequent composition path. Its central condition is:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kt\"\u003ebool\u003c/span\u003e \u003cspan class=\"n\"\u003eVideoFrameSubmitter\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003eShouldSubmit\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003econst\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eis_surface_visible_\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003eis_page_visible_\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003eforce_submit_\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eOn the ordinary path, both the video surface and the page must meet the visibility conditions. \u003ccode\u003eforce_submit_\u003c/code\u003e is an exemption path that I will return to later.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/156.0.8078.81/third_party/blink/renderer/platform/graphics/video_frame_submitter.cc\"\u003eSubmitFrame\u003c/a\u003e returns \u003ccode\u003efalse\u003c/code\u003e when the submission condition is not met.\u003c/p\u003e\n\u003cp\u003eAt this point, “the browser stops producing video while offscreen” was too vague. \u003cstrong\u003eDecoding video frames and submitting them to the compositor are different steps.\u003c/strong\u003e Page visibility directly affects this submission path; whether decoding stops still needs runtime evidence.\u003c/p\u003e\n\u003cp\u003eBut stopping submission seems to explain only a frozen picture. Why did the last frame disappear too, leaving the video area showing its background?\u003c/p\u003e\n\u003ch2 id=\"why-black-video-instead-of-the-last-frame\"\u003eWhy black video instead of the last frame?\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eUpdateSubmissionState\u003c/code\u003e contains a less obvious resource-cleanup path.\u003c/p\u003e\n\u003cp\u003eWhen \u003ccode\u003eShouldSubmit()\u003c/code\u003e is true, it attempts to submit the current frame. When false, it schedules \u003ccode\u003eSubmitEmptyFrameIfNeeded\u003c/code\u003e after \u003cstrong\u003e500 milliseconds\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eThe source comments explain two considerations: stopping offscreen submission avoids creating and accumulating GPU resources on the receiving side; sending an empty compositor frame can then release resources held by the previous video frame.\u003c/p\u003e\n\u003cp\u003eThe delay also has a reason. Page hiding and automatic Picture-in-Picture activation can arrive at different times. Clearing immediately could cause a flicker before PiP enables forced submission, so cleanup is checked a little later.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eSubmitEmptyFrameIfNeeded\u003c/code\u003e rechecks the submission condition, compositor sink, and frame size. Once the cleanup preconditions pass, it calls \u003ccode\u003eSubmitEmptyFrame\u003c/code\u003e. The core operation can be summarized as:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Simplified: thread checks, metadata, and notifications omitted.\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003ecompositor_frame_sink_\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003elast_frame_id_\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehas_value\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003elast_frame_id_\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ereset\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eauto\u003c/span\u003e \u003cspan class=\"n\"\u003eframe\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eCreateCompositorFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003enullptr\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ecompositor_frame_sink_\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003eSubmitCompositorFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003estd\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003emove\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe \u003ccode\u003enullptr\u003c/code\u003e matters: no actual video frame is passed into the compositor-frame construction.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eCreateCompositorFrame\u003c/code\u003e calls \u003ccode\u003eAppendQuads\u003c/code\u003e to add video drawing primitives only when \u003ccode\u003evideo_frame\u003c/code\u003e is non-null. A quad is a drawing primitive used by the compositor. This path therefore submits \u003cstrong\u003ea compositor frame without video drawing quads\u003c/strong\u003e; it does not make the decoder produce a black image.\u003c/p\u003e\n\u003cp\u003eThis gives separate explanations for no further updates and the disappearance of the old picture: visibility blocks normal submission, then the empty frame clears the previous video content. The window and its other interface elements remain, but the video region loses its content.\u003c/p\u003e\n\u003cp\u003eSource code containing a path does not prove that a particular recording followed it. Runtime evidence was the next step.\u003c/p\u003e\n\u003ch2 id=\"tracing-where-video-disappears-at-runtime\"\u003eTracing where video disappears at runtime\u003c/h2\u003e\n\u003cp\u003eBy this point, Chrome had updated to \u003cstrong\u003e157.0.8103.40\u003c/strong\u003e. I checked \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/157.0.8103.40/third_party/blink/renderer/platform/graphics/video_frame_submitter.cc\"\u003ethe matching version\u0026rsquo;s source\u003c/a\u003e, confirmed that its video submitter file was identical to the earlier 156 version, and collected a trace for the current version.\u003c/p\u003e\n\u003ch3 id=\"1-reproducing-black-video-while-recording-internal-browser-events\"\u003e1. Reproducing black video while recording internal browser events\u003c/h3\u003e\n\u003cp\u003eI continued using the local test page from the Safari investigation, playing the local reference footage with encoded frame numbers and performing no Canvas draws.\u003c/p\u003e\n\u003cp\u003eThe page recorded actual \u003ccode\u003evisibilitychange\u003c/code\u003e events and video metrics; Gecko recorded the window externally; Chrome\u0026rsquo;s trace recorded media-thread and composition-pipeline events.\u003c/p\u003e\n\u003cp\u003eTo avoid interference from existing tabs and settings, Chrome launched with a fresh, isolated profile. The trace\u0026rsquo;s command-line metadata confirmed that the occlusion-backgrounding flag was absent.\u003c/p\u003e\n\u003cp\u003eThis ordinary Chrome window reproduced the same symptom: the video\u0026rsquo;s recorded region stayed dark while away and recovered on return. It was recorded separately from the native-fullscreen frame-number comparison and was used to correlate the actual black interval with internal browser events.\u003c/p\u003e\n\u003cp\u003eSampling only the video region of this recording at two images per second over 16–39 seconds produced 46 identical grayscale samples, all with zero pixels brighter than 180. This interval lacked dynamic video content.\u003c/p\u003e\n\u003cp\u003eAcross the session, \u003cstrong\u003eall 1491 complete frames received by Gecko were written, with zero writer drops\u003c/strong\u003e. Pulse\u0026rsquo;s measured average rate was about \u003cstrong\u003e59.77 Hz\u003c/strong\u003e. This was not a case of Pulse failing to run or the writer dropping the pictures it received.\u003c/p\u003e\n\u003ch3 id=\"2-checking-whether-decoding-stopped\"\u003e2. Checking whether decoding stopped\u003c/h3\u003e\n\u003cp\u003eI identified the target page\u0026rsquo;s renderer in the trace, then filtered its \u003ccode\u003eMojoVideoDecoder::OnVideoFrameDecoded\u003c/code\u003e events rather than mixing in other pages or extensions.\u003c/p\u003e\n\u003cp\u003eThe actual page-hidden interval lasted \u003cstrong\u003e29.0073 seconds\u003c/strong\u003e. During the corresponding trace interval:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eObservation\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eResult\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDecode-completion events\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e867\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDistinct media timestamps\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e867\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eFirst and last media timestamps\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e11.033333 → 39.900000 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDecoder backend\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eVideoToolboxVideoDecoder\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eFrame format and size\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eNV12, 1280×720\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eFrame descriptions in those events contained advancing media timestamps, rather than repeated notifications of the same frame.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eThis black-video interval was not caused by decoding stopping: 867 distinct timestamps showed that decoding continued.\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"3-checking-whether-decoded-frames-were-actually-submitted\"\u003e3. Checking whether decoded frames were actually submitted\u003c/h3\u003e\n\u003cp\u003eThe trace still contained \u003ccode\u003eVideoFrameSubmitter::SubmitFrame\u003c/code\u003e events. Counting by name alone would make it easy to conclude that video was being submitted throughout.\u003c/p\u003e\n\u003cp\u003eBut the trace event is recorded \u003cstrong\u003ebefore\u003c/strong\u003e checking the submission condition:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Execution order: trace the function entry, then check submission eligibility.\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eTRACE_EVENT1\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;media\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;VideoFrameSubmitter::SubmitFrame\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;frame\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e             \u003cspan class=\"n\"\u003evideo_frame\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003eAsHumanReadableString\u003c/span\u003e\u003cspan class=\"p\"\u003e());\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003ecompositor_frame_sink_\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eShouldSubmit\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nb\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eEntering \u003ccode\u003eSubmitFrame\u003c/code\u003e does not mean a video frame was successfully submitted.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eSubmitEmptyFrame\u003c/code\u003e has a similar issue: it traces before checking the previous frame ID and can return immediately afterward. Its name appearing in a trace is not sufficient proof that an empty frame was sent.\u003c/p\u003e\n\u003cp\u003eI needed to follow the event downstream: identify the target video\u0026rsquo;s FrameSink, then check GPU reception and the corresponding surface\u0026rsquo;s \u003ccode\u003eCommitFrame\u003c/code\u003e. A FrameSink identifies a compositor-frame submission channel; a surface holds the content submitted through that channel. Submissions for other browser-window content could not be counted as video submissions.\u003c/p\u003e\n\u003cp\u003eApproximately aligning page time with the trace\u0026rsquo;s navigation timestamp yielded:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eTime relative to the page becoming hidden\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eEvent on the target video path\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAbout 501 milliseconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eEntered \u003ccode\u003eSubmitEmptyFrame\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAbout 355 microseconds after that entry\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eGPU received a compositor frame for the target video sink\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAbout 381 microseconds after that entry\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003eCommitFrame\u003c/code\u003e occurred on the surface corresponding to the same sink\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eRest of the offscreen interval\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe target video surface no longer committed continuously\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAfter returning to the original desktop\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe same video surface resumed continuous commits\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eBoth microsecond intervals are measured from that \u003ccode\u003eSubmitEmptyFrame\u003c/code\u003e entry. The page-to-trace clock mapping has uncertainty; the numbers establish ordering and temporal proximity, not precise end-to-end latency.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eAcross the entire roughly 29-second hidden interval, that video surface had only this one commit.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003ePlotting decoding and video submission for the same interval makes the contrast clear:\u003c/p\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_320x.avif 320w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_480x.avif 480w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_720x.avif 720w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_320x.webp 320w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_480x.webp 480w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_720x.webp 720w, /posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/chrome-offscreen-video-black/chrome-decode-and-submit-en.png\" alt=\"Decoded media timestamps keep advancing while target video-surface commits stop\"\n         width=\"1080\" height=\"627\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eEach point on the upper track is a decode-completion event, with media timestamp on the vertical axis. Each vertical mark on the lower track is one commit by the target video surface. Only the target page and video channel are included, not other browser surfaces.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eThe blue decoding trajectory continued. The green video submissions disappeared after one cleanup and resumed on return. That matches the source\u0026rsquo;s normal-submission gate, approximately 500-millisecond empty-frame cleanup, and missing video in the recording.\u003c/p\u003e\n\u003cp\u003eThe trace did not directly record the compositor frame\u0026rsquo;s complete quad payload. The empty-frame-cleanup interpretation rests on the combined target identity, timing, reception, commit, matching source, and recorded result.\u003c/p\u003e\n\u003ch2 id=\"conclusion-chrome-suppresses-video-submission-and-clears-the-old-frame\"\u003eConclusion: Chrome suppresses video submission and clears the old frame\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eFor the tested non-DRM video scenario, the cause was this: window occlusion changed page visibility, so the video submitter no longer met the normal submission condition. It then cleared the old video content through an empty compositor frame, leaving external window recording without the video picture. Decoding could still continue.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe changes on departure and return can be summarized as follows:\u003c/p\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_320x.avif 320w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_480x.avif 480w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_720x.avif 720w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_320x.webp 320w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_480x.webp 480w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_720x.webp 720w, /posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/chrome-offscreen-video-black/chrome-black-screen-sequence-en.png\" alt=\"Chrome video submission suppression, old-frame cleanup, and recovery after returning\"\n         width=\"1080\" height=\"770\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eArrows represent state propagation and content changes, not precise call latency. Decoding can continue while normal submission is suppressed. The launch flag changes Chrome\u0026rsquo;s visibility handling; Pulse improves subsequent offscreen window-frame supply.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003ePutting the layers back together:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eStage\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eAfter switching away\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eAfter returning\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePage visibility\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eBecomes hidden\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eReturns to visible\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eVideo decoding\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eContinues producing frames with different timestamps in this run\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eContinues\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eNormal video submission\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eSuppressed by visibility conditions\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eResumes\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePrevious video content\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eEnters the empty-compositor-frame cleanup path\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNew video frames are submitted again\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eExternal recording\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eInterface and audio can continue, but video content is missing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eVideo picture returns\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThis explains why Pulse alone did not solve it. Pulse can improve offscreen frame supply, but it cannot make Chrome bypass its internal video-submission condition. Even continuous complete frames cannot reconstruct video that is missing from the window\u0026rsquo;s composited content.\u003c/p\u003e\n\u003cp\u003eThe Chrome flag intervenes at the visibility entry point. Pulse improves subsequent offscreen frame supply. Their different roles explain the observation that updates stayed sparse with the flag but Pulse off, and became continuous when both were enabled.\u003c/p\u003e\n\u003cp\u003eThis shares an upstream clue with \u003ca href=\"/en/posts/safari-offscreen-video-black/\"\u003ethe Safari investigation\u003c/a\u003e, but the backend actions differ:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eBrowser\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eVideo-presentation action in the examined version\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eEffective diagnostic intervention\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSafari\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eMedia invisibility reaches the AVFoundation backend, which hides the video layer\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOne Canvas draw; disabling occlusion detection in a custom WKWebView\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eChrome\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNormal video submission is suppressed, and previous video-frame content is cleared\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe particular launch flag together with Pulse\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eSafari\u0026rsquo;s \u003ccode\u003esetHidden:\u003c/code\u003e should not be attributed to Chrome, and Chrome\u0026rsquo;s empty compositor frame should not be treated as Safari\u0026rsquo;s implementation. What connected the investigations was how browsers change video presentation when a window becomes invisible.\u003c/p\u003e\n\u003cp\u003eThe flag comparison used Chrome 156; the default-configuration trace used Chrome 157, on macOS 26.6.2. They answer intervention and runtime-behavior questions respectively. This is not a claim that every Chrome version, website, or protected video source has been tested.\u003c/p\u003e\n\u003ch2 id=\"what-can-gecko-control\"\u003eWhat can Gecko control?\u003c/h2\u003e\n\u003cp\u003eWith the cause located, the next question was whether Gecko could intervene somewhere in this chain and keep the original Chrome window supplying video while offscreen.\u003c/p\u003e\n\u003cp\u003eThere were two direct possibilities: keep the page visible upstream, or allow a hidden page to keep submitting video downstream. I also needed to understand whether the earlier Canvas read could activate an internal browser capture keep-alive mechanism.\u003c/p\u003e\n\u003ch3 id=\"1-upstream-can-the-page-stay-visible\"\u003e1. Upstream: can the page stay visible?\u003c/h3\u003e\n\u003cp\u003eThis path already had a positive result: \u003ccode\u003e--disable-backgrounding-occluded-windows\u003c/code\u003e treats Chrome\u0026rsquo;s \u003ccode\u003eOCCLUDED\u003c/code\u003e state as \u003ccode\u003eVISIBLE\u003c/code\u003e. The POC also showed that Pulse restored consecutive source frames under that launch condition.\u003c/p\u003e\n\u003cp\u003eThe upstream visibility path can therefore be changed. The limitation is that the flag is a browser launch condition requiring Chrome to restart. It is not an option Gecko can immediately apply to a selected window in an already running browser.\u003c/p\u003e\n\u003cp\u003eChromium\u0026rsquo;s own internal capture also cooperates at the visibility layer. \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/157.0.8103.40/content/browser/media/capture/web_contents_frame_tracker.cc\"\u003eWebContentsFrameTracker\u003c/a\u003e increments a capturer count when internal capture actually starts and releases it when capture ends. When \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/157.0.8103.40/content/browser/web_contents/web_contents_impl.cc\"\u003eWebContentsImpl\u003c/a\u003e calculates page visibility, a positive visible-capturer count makes it treat the page as \u003ccode\u003ekVisible\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThe browser therefore already has logic for keeping a page visible while it is being captured. But an external Gecko SCK window-capture session does not automatically register that internal count, and I did not find a public entry point for registering it using only an SCK window ID.\u003c/p\u003e\n\u003cp\u003eAn extension\u0026rsquo;s \u003ccode\u003etabCapture\u003c/code\u003e can lead into the browser\u0026rsquo;s internal capture path, but it requires permissions, a user trigger, and a capture lifecycle that actually starts. That is a separate, browser-cooperating approach; this investigation did not verify whether it also restores external SCK recording of the original window.\u003c/p\u003e\n\u003ch3 id=\"2-downstream-can-the-video-submission-condition-be-bypassed\"\u003e2. Downstream: can the video submission condition be bypassed?\u003c/h3\u003e\n\u003cp\u003eCould video still be submitted while the page remained hidden? The earlier \u003ccode\u003eShouldSubmit()\u003c/code\u003e condition offered another clue: even when visibility conditions fail, \u003ccode\u003eforce_submit_\u003c/code\u003e permits submission if true.\u003c/p\u003e\n\u003cp\u003eThat is why I followed this variable. It might expose an exemption reserved for special browser scenarios. Who sets it, and under what conditions?\u003c/p\u003e\n\u003cp\u003eFollowing the callers in Chrome 157\u0026rsquo;s \u003ca href=\"https://chromium.googlesource.com/chromium/src/\u0026#43;/refs/tags/157.0.8103.40/third_party/blink/renderer/platform/media/web_media_player_impl.cc\"\u003eWebMediaPlayerImpl\u003c/a\u003e showed one direct source in the ordinary video player: video Picture-in-Picture. Changing display type to video PiP tells the compositor to enable forced submission.\u003c/p\u003e\n\u003cp\u003eHowever, the ordinary video-PiP path examined here also removes the video compositing layer from the original webpage. Forced submission serves PiP presentation. It does not directly preserve video inside the original, otherwise unchanged Chrome window for offscreen recording.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eforce_submit_\u003c/code\u003e is thus a clue to an internal exemption, not a switch already exposed to Gecko. I did not find a public way for external Gecko to set it directly.\u003c/p\u003e\n\u003ch3 id=\"3-back-to-canvas-does-requesting-a-frame-keep-it-presented\"\u003e3. Back to Canvas: does requesting a frame keep it presented?\u003c/h3\u003e\n\u003cp\u003eThe earlier single Canvas draw had not restored Chrome\u0026rsquo;s video. Source investigation also helped explain how to interpret that result.\u003c/p\u003e\n\u003cp\u003eThe ordinary player has a condition named \u003ccode\u003eIsVideoBeingCaptured\u003c/code\u003e, which checks whether frames have been requested recently. Internal reads such as Canvas drawing may update the relevant time and affect some background-media optimizations.\u003c/p\u003e\n\u003cp\u003eBut this recent-frame-request state is not the visible-capturer count described above, and it does not automatically set \u003ccode\u003eforce_submit_\u003c/code\u003e to true. Obtaining a frame and allowing continued video submission remain different conditions.\u003c/p\u003e\n\u003cp\u003eThat is why Safari\u0026rsquo;s recovery method cannot simply be carried over to Chrome: a single draw does not guarantee bypassing Chrome\u0026rsquo;s submission gate. A Canvas call completing without an exception is also not proof that valid dynamic video pixels were obtained.\u003c/p\u003e\n\u003cp\u003eFor Gecko, the tested path remains launching Chrome with the specific flag and using Pulse, which requires changing the browser\u0026rsquo;s launch conditions. Internal capture keep-alive and forced submission have real uses, but I have not found a general repair that external Gecko can apply directly to arbitrary running windows without Chrome\u0026rsquo;s cooperation.\u003c/p\u003e\n\u003cp\u003eThe reproduction, recordings, page data, and trace were submitted as \u003ca href=\"https://issues.chromium.org/issues/584503585\"\u003eChromium #584503585\u003c/a\u003e and added to the related Apple Feedback \u003cstrong\u003eFB25368989\u003c/strong\u003e.\u003c/p\u003e\n\u003ch2 id=\"what-finally-broke-the-deadlock\"\u003eWhat finally broke the deadlock\u003c/h2\u003e\n\u003cp\u003eLooking back, the most time-consuming part was not just black video. It was the contradiction between working earlier and failing later. That repeatedly pulled attention back to the capture side, making a browser launch-condition difference look like a regression in Pulse or recording code.\u003c/p\u003e\n\u003cp\u003eThe history command revealed the key difference and moved the investigation from “why can the recorder not obtain the picture?” to “is the browser still submitting video?” Safari and Chrome\u0026rsquo;s investigations ultimately met at visibility policy.\u003c/p\u003e\n\u003cp\u003eThe most difficult recording problem I encountered while building Gecko finally had an explanation. There is still no general workaround Gecko can enable directly, but the investigation took more than a few unexpected turns before getting here.\u003c/p\u003e\n\u003cp\u003eThe lesson I will remember: do not give up too quickly. When there is no clear direction, take a break. Revisit the history—you might find a clue you missed. 😁\u003c/p\u003e\n","date_published":"2026-11-13T15:05:09+08:00","tags":["Gecko","macOS","ScreenCaptureKit","Chrome","Chromium"]},{"title":"Why Safari Videos Go Black in ScreenCaptureKit Recordings When Switching macOS Spaces","id":"https://liudon.com/en/posts/safari-offscreen-video-black/","url":"https://liudon.com/en/posts/safari-offscreen-video-black/","summary":"Why does Safari video turn black in a Mac window recording after switching Spaces? Local video tests, OBS comparisons, Canvas experiments, WebKit source, and runtime logs reveal the visibility policy behind it.","content_html":"\u003cp\u003eDuring application compatibility testing for \u003ca href=\"/posts/gecko-macos-app-recorder/\"\u003eGecko\u003c/a\u003e (in Chinese), I ran into a Safari problem: recording worked while the window was on the current desktop, but switching to another macOS Space made the video region in the recording turn black. Audio continued, and recording did not stop. Returning to the original Space restored the picture.\u003c/p\u003e\n\u003cp\u003eI had already substantially improved QuickTime\u0026rsquo;s offscreen freezes with \u003ca href=\"/posts/gecko-adaptive-pulse-design/\"\u003ePulse\u003c/a\u003e (in Chinese). This time, Safari\u0026rsquo;s video content disappeared altogether. Adjusting the refresh frequency did not bring it back.\u003c/p\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/safari-offscreen-video-black/safari-offscreen-video-black.mp4.poster.webp\"\n        width=\"640\"\n        height=\"400\"\n        style=\"--video-ratio: 640 / 400\"\n    \u003e\n        \u003csource\n            src=\"/posts/safari-offscreen-video-black/safari-offscreen-video-black.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003cp\u003e\u003cem\u003eThe original symptom: from 00:08 to 00:20, I am on another Space and the video region turns black. The recordings in this article retain their original interface language.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eWas the problem in Gecko, ScreenCaptureKit, or Safari? Why could I still record the browser interface while the video itself disappeared?\u003c/p\u003e\n\u003ch2 id=\"isolating-the-capture-pipeline-with-a-local-video-page\"\u003eIsolating the capture pipeline with a local video page\u003c/h2\u003e\n\u003cp\u003eThe compatibility test used an online video, with several factors outside my control: player scripts, network loading, cross-origin content, and potentially media protection. A black recording alone could not tell me which part had failed.\u003c/p\u003e\n\u003cp\u003eI built a local webpage that let Safari play a locally selected video, then recorded that window. The video did not go through a website, download service, or decryption step. I used the reference footage with encoded frame numbers from my \u003ca href=\"/posts/gecko-recording-test-automation/\"\u003erecording test automation work\u003c/a\u003e (in Chinese), so that static source content could be distinguished from missing captured content.\u003c/p\u003e\n\u003cp\u003eA local HTTP server served the page. A file picker supplied the local video, which the page passed to a \u003ccode\u003e\u0026lt;video\u0026gt;\u003c/code\u003e element using a Blob URL.\u003c/p\u003e\n\u003cp\u003eSafari still played the video, and Gecko still captured its window externally.\u003c/p\u003e\n\u003ch3 id=\"what-the-test-page-does\"\u003eWhat the test page does\u003c/h3\u003e\n\u003cp\u003eThe following experiments all used this local test page structure: a video input, a display area, phase controls, and event logging. I changed the display path or drawing behavior to answer each question.\u003c/p\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/safari-offscreen-video-black/safari-test-page.png_320x.avif 320w, /posts/safari-offscreen-video-black/safari-test-page.png_480x.avif 480w, /posts/safari-offscreen-video-black/safari-test-page.png_720x.avif 720w, /posts/safari-offscreen-video-black/safari-test-page.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/safari-offscreen-video-black/safari-test-page.png_320x.webp 320w, /posts/safari-offscreen-video-black/safari-test-page.png_480x.webp 480w, /posts/safari-offscreen-video-black/safari-test-page.png_720x.webp 720w, /posts/safari-offscreen-video-black/safari-test-page.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/safari-offscreen-video-black/safari-test-page.png\" alt=\"English interface of the local Safari video test page\"\n         width=\"1080\" height=\"919\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eThe original test page, shared with the Chinese article. Its controls are in Chinese: the input and video are at the top, playback and Space-switch markers are in the middle, and the summary appears below. A video is selected, but logging has not started.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eThe page has four jobs:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eVideo input and display:\u003c/strong\u003e select a local MP4 and play it in a fixed area.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePhase markers:\u003c/strong\u003e mark playback start, departure, return, and finish. These buttons record the intended operation; \u003ccode\u003evisibilitychange\u003c/code\u003e records when the page actually becomes hidden or visible.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSource observation:\u003c/strong\u003e record playback and video-frame progression. I will explain the individual metrics when looking at Safari\u0026rsquo;s state.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eResult export:\u003c/strong\u003e save the events as JSON for comparison with the externally recorded MP4.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe procedure stayed the same: play the video on the current desktop, switch to a Space without Safari, wait, then return and stop.\u003c/p\u003e\n\u003cp\u003eThe result: \u003cstrong\u003ea local page playing a local video also lost its video picture while offscreen.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eHere is one recording. Approximately 16–39 seconds is the stable offscreen interval: video content is missing, then returns when I switch back.\u003c/p\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/safari-offscreen-video-black/safari-control.mp4.poster.webp\"\n        width=\"1080\"\n        height=\"650\"\n        style=\"--video-ratio: 1080 / 650\"\n    \u003e\n        \u003csource\n            src=\"/posts/safari-offscreen-video-black/safari-control.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003cp\u003eI also tried OBS window recording and saw the same offscreen black-video symptom. It was not unique to Gecko. Together with the pixel checks before file writing, this shifted attention toward the boundary between Safari\u0026rsquo;s video presentation and window capture.\u003c/p\u003e\n\u003ch3 id=\"at-which-stage-does-the-picture-go-black\"\u003eAt which stage does the picture go black?\u003c/h3\u003e\n\u003cp\u003eThe next distinction was whether the file was being written incorrectly or whether SCK was already returning incomplete content.\u003c/p\u003e\n\u003cp\u003eIn the original video scenario, I sampled the pixel buffer received in SCK callbacks and the copied buffer passed to the writer, using the same whole-window and central-region measurements:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003ePhase\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eSCK whole-window near-black ratio\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eWriter whole-window near-black ratio\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eSCK central-region near-black ratio\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eWriter central-region near-black ratio\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eBefore switching away\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e25%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e25%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e29%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e29%\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eWhile away\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e40%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e40%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e87%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e87%\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAfter returning\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e25%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e25%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e29%\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e29%\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe statistics matched at both points in all 27 once-per-second sample pairs. During the offscreen interval, the writer\u0026rsquo;s written-frame count also increased from 230 to 505, so samples were still arriving.\u003c/p\u003e\n\u003cp\u003eThe increase in near-black pixels in the central region was present \u003cstrong\u003ebefore the encoder\u003c/strong\u003e. The priority was therefore \u003cstrong\u003eSafari presentation → window composition → SCK content acquisition\u003c/strong\u003e, rather than MP4 muxing or finalization.\u003c/p\u003e\n\u003cp\u003eLocal footage reproduced it, another recorder reproduced it, and content was already missing before Gecko wrote the file. I now needed to observe what Safari did while offscreen.\u003c/p\u003e\n\u003ch2 id=\"is-playback-still-advancing-behind-the-black-picture\"\u003eIs playback still advancing behind the black picture?\u003c/h2\u003e\n\u003cp\u003eThis is where the page\u0026rsquo;s individual metrics become useful. They observe different points in the pipeline:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eMetric\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eWhat it records\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eWhat it helps establish\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003edocument.visibilityState\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhether the browser considers the page visible or hidden\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhen the page actually becomes hidden and recovers\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003evideo.currentTime\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe current playback position\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhether the playback timeline advances\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003erequestVideoFrameCallback\u003c/code\u003e (rVFC)\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eVideo-frame-related callbacks\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhether the browser still delivers frame notifications\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCallback \u003ccode\u003emediaTime\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe media timestamp associated with the callback\u0026rsquo;s frame\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhether the corresponding content position advances\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCallback \u003ccode\u003epresentedFrames\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe cumulative number of video frames submitted for composition\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhether the frame metric increases during playback\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eWith only state and callbacks being recorded, the actual hidden interval yielded:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eMetric\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eObservation\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePage hidden duration\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e12.984 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSaved rVFC samples\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e62\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003emediaTime\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e7.300 → 19.967 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003epresentedFrames\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e219 → 599\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eAcross those samples, the progression rate was approximately \u003ccode\u003e(599-219)/(19.967-7.300) ≈ 30 FPS\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThat rules out the simplistic explanation that the video entirely stopped when I switched away. But rVFC is not a pixel-level check of WindowServer or SCK: continuing frame callbacks do not guarantee that the native video layer is present in the window\u0026rsquo;s composited content.\u003c/p\u003e\n\u003cp\u003eThe next question was more specific: \u003cstrong\u003ecould Safari still supply usable video pixels, while the native \u003ccode\u003e\u0026lt;video\u0026gt;\u003c/code\u003e presentation path failed to appear in the external recording?\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"using-canvas-to-separate-frame-access-from-presentation\"\u003eUsing Canvas to separate frame access from presentation\u003c/h2\u003e\n\u003cp\u003eWhen I gave the source logs and recordings to Claude, it suggested a comparison: draw the same video onto a Canvas, record that Canvas, and see whether it still went black offscreen.\u003c/p\u003e\n\u003cp\u003eThis kept the playback source unchanged while changing how its pixels reached the displayed page. If Canvas could be recorded but native video could not, I could separate access to video frames from native video presentation.\u003c/p\u003e\n\u003ch3 id=\"1-drawing-the-video-onto-a-visible-canvas\"\u003e1. Drawing the video onto a visible Canvas\u003c/h3\u003e\n\u003cp\u003eI kept the video as the playback source, placed it underneath the Canvas, and made it almost transparent. What the user saw was the video drawn on the Canvas.\u003c/p\u003e\n\u003cp\u003eOn each rVFC callback:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003efunction\u003c/span\u003e \u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003ectx\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edrawImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ecanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ewidth\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003ecanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eheight\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003erequestVideoFrameCallback\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003erequestVideoFrameCallback\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe two paths were:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eOriginal: video playback → native video presentation → window capture\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eNew:      video playback → drawImage → visible Canvas → window capture\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe Canvas video remained recordable while offscreen.\u003c/p\u003e\n\u003cp\u003eThe page also periodically computed an RGB fingerprint from a 16×9 Canvas thumbnail to check that it was obtaining different pixels. During 13.934 seconds of actual hidden time, all 34 saved fingerprints were different. The Canvas also visibly changed in the resulting recording.\u003c/p\u003e\n\u003cp\u003eUnder these conditions, Safari could still supply video pixels, and SCK could capture dynamic content presented through Canvas. However, I had changed both the display layer and the active drawing behavior. I could not yet tell which mattered.\u003c/p\u003e\n\u003ch3 id=\"2-keeping-native-video-visible-while-hiding-the-canvas\"\u003e2. Keeping native video visible while hiding the Canvas\u003c/h3\u003e\n\u003cp\u003eNext, I displayed the native video normally and set the Canvas to \u003ccode\u003edisplay:none\u003c/code\u003e. The rVFC loop and continuous \u003ccode\u003edrawImage\u003c/code\u003e calls remained.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003estyle\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eopacity\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;1\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003ecanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003estyle\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edisplay\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;none\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Continue calling drawImage(video) on each video-frame callback.\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIf the previous result only came from bypassing the native video layer with a visible Canvas, hiding the Canvas and displaying native video again should have brought the black picture back.\u003c/p\u003e\n\u003cp\u003eInstead, \u003cstrong\u003ethe native video also remained visible in the recording while offscreen.\u003c/strong\u003e During 14.868 seconds of actual hidden time, all 37 Canvas fingerprints were different, and the recorded native video kept playing.\u003c/p\u003e\n\u003cp\u003eThe Canvas itself was invisible, so the restored content was not the Canvas. \u003cstrong\u003eActively drawing the video appeared to affect the native video\u0026rsquo;s state.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThese first two experiments were separate sessions, though. They linked continuous drawing with avoiding black video, but a difference between sessions was not yet a strong causal test.\u003c/p\u003e\n\u003ch3 id=\"3-starting-continuous-hidden-canvas-drawing-after-the-picture-goes-black\"\u003e3. Starting continuous hidden-Canvas drawing after the picture goes black\u003c/h3\u003e\n\u003cp\u003eThis time, the intervention happened within one recording, keeping the same window, visible video, and hidden Canvas. Drawing started disabled. After the page became hidden, I waited about seven seconds before enabling continuous drawing.\u003c/p\u003e\n\u003cp\u003eNo manual page interaction was needed while away. The hidden event started the wait, but drawing had to occur when a callback actually arrived. I logged the first completed draw rather than treating a scheduled operation as an executed one.\u003c/p\u003e\n\u003cp\u003eThe data split into two intervals:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eInterval within the same hidden period\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eDuration\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eDrawing and pixel records\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eRecorded result\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eBefore enabling drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e7.054 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eZero draws; frame callbacks continued\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eBlack video\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eAfter enabling drawing, before returning\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e14.130 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eContinuous drawing; 34 distinct fingerprints\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eVideo recovered and kept moving\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eRecovery happened while the page was still hidden, without returning to the original desktop. This was stronger evidence than a successful separate recording: the window and displayed content stayed the same; active drawing was what changed.\u003c/p\u003e\n\u003cp\u003eOne question remained: did recovery require continuous refreshes, or could one operation change the subsequent state?\u003c/p\u003e\n\u003ch3 id=\"4-drawing-just-once-to-the-hidden-canvas-after-the-picture-goes-black\"\u003e4. Drawing just once to the hidden Canvas after the picture goes black\u003c/h3\u003e\n\u003cp\u003eI reduced the intervention to a single draw. The page first remained hidden without drawing, allowing the black picture to appear. Then it executed one \u003ccode\u003edrawImage(video)\u003c/code\u003e into the hidden Canvas, and I observed whether recovery persisted.\u003c/p\u003e\n\u003cp\u003eOne draw, no pixel readback. The page still displayed native video, and the Canvas remained hidden.\u003c/p\u003e\n\u003cp\u003eHere is the simplified logic. The wait begins at actual hidden state; rVFC provides an observation and execution opportunity. A timer wakeup alone is not counted as a successful draw:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nx\"\u003ehiddenSince\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enull\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nx\"\u003edrawn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nx\"\u003edraws\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eaddEventListener\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;visibilitychange\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ehidden\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"nx\"\u003ehiddenSince\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eperformance\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"nx\"\u003ehiddenSince\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enull\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003efunction\u003c/span\u003e \u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"nx\"\u003edrawn\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"nx\"\u003ehiddenSince\u003c/span\u003e \u003cspan class=\"o\"\u003e!==\u003c/span\u003e \u003cspan class=\"kc\"\u003enull\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"nx\"\u003eperformance\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"nx\"\u003ehiddenSince\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;=\u003c/span\u003e \u003cspan class=\"mi\"\u003e7000\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003ectx\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edrawImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e960\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e540\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003edrawn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003edraws\u003c/span\u003e\u003cspan class=\"o\"\u003e++\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003erecord\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e \u003cspan class=\"nx\"\u003ekind\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;one_shot_draw_completed\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003edraws\u003c/span\u003e \u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003erequestVideoFrameCallback\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003evideo\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003erequestVideoFrameCallback\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eonFrame\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cem\u003eCore logic for one offscreen interval. \u003ccode\u003erecord\u003c/code\u003e is the page\u0026rsquo;s event logger; there is no pixel readback and no second draw.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eThe measured results, relative to the moment the page entered hidden state:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eEvent or interval\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eResult\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eFirst and only draw\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e7.004 seconds after hidden\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eTime still hidden after drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e12.497 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSaved frame-callback samples before drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e33, with cumulative draw count 0\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSaved frame-callback samples after drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e59, with cumulative draw count remaining 1\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eHeartbeats after drawing\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e13, with cumulative draw count remaining 1\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCPU pixel readback\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNone\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eRecorded result\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eBlack initially; sustained recovery after the single draw, without returning to the desktop\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_320x.avif 320w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_480x.avif 480w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_720x.avif 720w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_320x.webp 320w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_480x.webp 480w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_720x.webp 720w, /posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/safari-offscreen-video-black/one-shot-draw-timeline-en.png\" alt=\"Event timeline for a single draw into a hidden Canvas\"\n         width=\"1080\" height=\"619\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eThe upper track shows saved source-side frame metrics; the lower track shows the draw count. This is a webpage event timeline, not a millisecond-accurate boundary for recovery in the recording.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eTo check whether the picture simply recovered on its own after enough time, I also interleaved no-draw and single-draw intervals. All three no-draw intervals stayed black until return; all three single-draw intervals recovered after drawing.\u003c/p\u003e\n\u003cp\u003eThe four experiments narrowed the explanations step by step:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eIntervention\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eWhat the result established\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eVisible Canvas displaying the video\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eVideo pixels remained accessible, and Canvas content could enter the recording\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eContinuous drawing into a hidden Canvas\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eThe effect extended to native video, not just the Canvas\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eStarting continuous drawing during the same hidden interval\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eDrawing could change an existing black-video state without returning\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDrawing only once after going hidden\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eContinuous refresh was unnecessary; recovery persisted after one draw\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe question was no longer “why can Canvas be recorded?” It was: \u003cstrong\u003ewhy would a single invisible draw affect the native video\u0026rsquo;s behavior for more than ten seconds afterward?\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"can-an-external-screenshot-reproduce-the-canvas-effect\"\u003eCan an external screenshot reproduce the Canvas effect?\u003c/h2\u003e\n\u003cp\u003eA single hidden-Canvas draw suggested a more direct possibility. Gecko could not directly operate an arbitrary Safari page, but could it request a window screenshot from the capture side and achieve something similar?\u003c/p\u003e\n\u003cp\u003eI used \u003ccode\u003eSCScreenshotManager.captureSampleBuffer\u003c/code\u003e to request a screenshot of the same Safari window after its video had gone black offscreen. I tried both a separate process and a call inside Gecko\u0026rsquo;s recording process.\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eCall location\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eScreenshot request\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eDid offscreen video recover?\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSeparate process\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eSuccessfully obtained a pixel buffer\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo; recovery waited until return\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eInside Gecko\u0026rsquo;s recording process\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eSuccessfully obtained a pixel buffer\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo; recovery waited until return\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eNeither reproduced the Canvas effect. \u003cstrong\u003eUnder these two tested arrangements, an external window screenshot could not substitute for \u003ccode\u003edrawImage(video)\u003c/code\u003e inside the webpage.\u003c/strong\u003e Obtaining a window buffer did not mean the browser would present the video again.\u003c/p\u003e\n\u003cp\u003eIf one internal draw worked but one external screenshot did not, the next question was: what did \u003ccode\u003edrawImage(video)\u003c/code\u003e change besides obtaining the current frame?\u003c/p\u003e\n\u003ch2 id=\"source-clue-page-visibility-and-media-visibility-are-not-the-same\"\u003eSource clue: page visibility and media visibility are not the same\u003c/h2\u003e\n\u003cp\u003eTo explain these observations, I turned to the source.\u003c/p\u003e\n\u003cp\u003eThe local Safari WebKit build was \u003ccode\u003e21624.5.1.11.3\u003c/code\u003e, matching the public release tag \u003ccode\u003eWebKit-7624.5.1.11.3\u003c/code\u003e at commit \u003ca href=\"https://github.com/WebKit/WebKit/tree/26695efdefc6076049ec50713a2c4e54b90b4d47\"\u003e\u003ccode\u003e26695efdefc6\u003c/code\u003e\u003c/a\u003e. All code below describing the tested implementation comes from that release commit.\u003c/p\u003e\n\u003ch3 id=\"1-a-canvas-draw-can-change-player-state\"\u003e1. A Canvas draw can change player state\u003c/h3\u003e\n\u003cp\u003eAfter obtaining the player, \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebCore/html/HTMLVideoElement.cpp#L380\"\u003eHTMLVideoElement::paintCurrentFrameInContext\u003c/a\u003e does this:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eplayer\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003eisVisibleForCanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eplayer\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003esetVisibleForCanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003evisibilityStateChanged\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eplayer\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003epaintCurrentFrameInContext\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edestRect\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIt first sets \u003ccode\u003evisibleForCanvas\u003c/code\u003e, then recalculates media visibility, and only afterward paints the current video frame.\u003c/p\u003e\n\u003cp\u003eThe function does not immediately reset the flag when drawing finishes. A single draw can therefore have an effect beyond the duration of that call, consistent with native video remaining restored afterward.\u003c/p\u003e\n\u003ch3 id=\"2-a-hidden-document-does-not-necessarily-mean-hidden-media\"\u003e2. A hidden document does not necessarily mean hidden media\u003c/h3\u003e\n\u003cp\u003eAfter handling branches such as fullscreen, \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebCore/html/HTMLMediaElement.cpp#L6981\"\u003eHTMLMediaElement::elementIsHidden\u003c/a\u003e uses this condition for ordinary video:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nf\"\u003eprotectedDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003ehidden\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003em_player\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003em_player\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003eisVisibleForCanvas\u003c/span\u003e\u003cspan class=\"p\"\u003e());\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eFor ordinary video with an existing player, that becomes:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eDocument hidden\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003evisibleForCanvas\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eMedia hidden\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003efalse\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003efalse\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003efalse\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003etrue\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003efalse\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003etrue\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003etrue\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003etrue\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e\u003cstrong\u003efalse\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003ePage-hidden and media-hidden states can differ. Media kept visible for Canvas is no longer classified as hidden solely because its document is hidden.\u003c/p\u003e\n\u003ch3 id=\"3-the-media-level-result-is-passed-to-the-player\"\u003e3. The media-level result is passed to the player\u003c/h3\u003e\n\u003cp\u003eIn the same file, \u003ccode\u003evisibilityStateChanged()\u003c/code\u003e stores the new \u003ccode\u003em_elementIsHidden\u003c/code\u003e value and, when it changes, calls:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eplayer\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"n\"\u003esetPageIsVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003em_elementIsHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe name can be misleading: the value passed down is not always equivalent to the page\u0026rsquo;s \u003ccode\u003edocument.visibilityState\u003c/code\u003e. Here it is the result of the media element\u0026rsquo;s visibility rules.\u003c/p\u003e\n\u003cp\u003eThe state then passes through the remote media player and GPU proxy to the AVFoundation backend. Source code established a propagation path; runtime logs would still be needed to check that actual Safari followed it.\u003c/p\u003e\n\u003ch3 id=\"4-the-backend-hides-the-video-layer\"\u003e4. The backend hides the video layer\u003c/h3\u003e\n\u003cp\u003eIn the matching release, \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebCore/platform/graphics/avfoundation/MediaPlayerPrivateAVFoundation.cpp#L596\"\u003eMediaPlayerPrivateAVFoundation::setPageIsVisible\u003c/a\u003e stores visibility, prepares video rendering when visibility returns, and invokes the platform implementation.\u003c/p\u003e\n\u003cp\u003eWhen a video layer exists, \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateAVFoundationObjC.mm#L1419\"\u003eplatformSetVisible\u003c/a\u003e runs the following inside a transaction with implicit animations disabled:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-objc\" data-lang=\"objc\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003em_videoLayer\u003c/span\u003e \u003cspan class=\"nl\"\u003esetHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eisVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e];\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThis produced a testable explanation: going offscreen hides the page; ordinary media becomes invisible; the backend hides the video layer. A single Canvas draw could change the media-level decision through \u003ccode\u003evisibleForCanvas\u003c/code\u003e, keeping media visible even when the document is hidden.\u003c/p\u003e\n\u003cp\u003eCanvas intervenes at the media decision: the document remains hidden, but media stays visible.\u003c/p\u003e\n\u003cp\u003eThat explained why a single operation could have a lasting effect, and led to two predictions:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eIf drawing changes state, drawing once \u003cstrong\u003ebefore\u003c/strong\u003e switching away could also work. There should be no need to wait for the picture to go black.\u003c/li\u003e\n\u003cli\u003eIf window occlusion could be prevented from hiding the page farther upstream, video presentation might survive without any Canvas drawing.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eI tested the predictions separately.\u003c/p\u003e\n\u003ch2 id=\"testing-the-first-prediction-can-one-draw-before-departure-prevent-black-video\"\u003eTesting the first prediction: can one draw before departure prevent black video?\u003c/h2\u003e\n\u003cp\u003eIf a single draw changes player visibility, it need not wait for a hidden page. Drawing once in the foreground and then switching away might prevent the problem.\u003c/p\u003e\n\u003cp\u003eThis differs from forcing a refresh after black video has already appeared, making it worth a separate A/B comparison.\u003c/p\u003e\n\u003ch3 id=\"controlling-the-experiment\"\u003eControlling the experiment\u003c/h3\u003e\n\u003cp\u003eThe local page compared no drawing with one draw before switching away. Both conditions used the same foreground preparation; the intended difference was whether the draw actually executed.\u003c/p\u003e\n\u003cp\u003eThe drawing gate checked that the page was \u003ccode\u003evisible\u003c/code\u003e, the video had a current frame, playback was active, and the video had not ended. It recorded attempts, successes, errors, and completion time. Once the gate ran, it closed: no retry and no extra draw.\u003c/p\u003e\n\u003cp\u003erVFC and heartbeats remained for logging only, not as drawing triggers.\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eObservation\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eNo draw\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eOne draw before departure\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSuccessful Canvas draws\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e1\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003ePixel readback\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eNone\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eNone\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eActual page-hidden duration\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e27.476 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e28.284 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDraw relative to hidden state\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eNone\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003ePage became hidden about 439 ms after draw completion\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eRecorded native video\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eBlank while away\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003eKept updating while away\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eHere is the original recording with the single draw. Approximately 11–35 seconds is the stable sampling interval used below; the barcode and moving color block keep changing:\u003c/p\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/safari-offscreen-video-black/safari-canvas-once.mp4.poster.webp\"\n        width=\"1080\"\n        height=\"650\"\n        style=\"--video-ratio: 1080 / 650\"\n    \u003e\n        \u003csource\n            src=\"/posts/safari-offscreen-video-black/safari-canvas-once.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003ch3 id=\"a-page-counter-cannot-stand-in-for-video-movement\"\u003eA page counter cannot stand in for video movement\u003c/h3\u003e\n\u003cp\u003eA changing page counter does not establish that the video itself is correct. I cropped the same rectangle \u003cstrong\u003einside the video\u003c/strong\u003e in both recordings, took two samples per second, and reduced them to grayscale, excluding browser UI, buttons, and counters:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eCheck\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eA: no draw\u003c/th\u003e\n\t\t\t\t\t\u003cth style=\"text-align: right\"\u003eB: one draw\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eMP4 interval analyzed\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e16–39 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e11–35 seconds\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eSampled images\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e46\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e48\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDistinct images\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e\u003cstrong\u003e1\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e\u003cstrong\u003e48\u003c/strong\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eBright pixels per image, grayscale \u0026gt;180\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e0\u003c/td\u003e\n\t\t\t\t\t\u003ctd style=\"text-align: right\"\u003e1232–1610\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eAll 46 samples in A were identical. All 48 samples in B were different.\u003c/p\u003e\n\u003cp\u003eMore importantly, the page that executed the draw still became hidden for 28 seconds. The draw did not bring the page to the foreground; it preserved video presentation.\u003c/p\u003e\n\u003cp\u003eThis gave directly observable experimental support for separating page visibility from media visibility.\u003c/p\u003e\n\u003ch2 id=\"testing-the-second-prediction-stop-occlusion-from-hiding-the-page\"\u003eTesting the second prediction: stop occlusion from hiding the page\u003c/h2\u003e\n\u003cp\u003eCanvas changed the media-level decision. I also needed to investigate why the document became hidden in the first place.\u003c/p\u003e\n\u003cp\u003eFollowing WebKit\u0026rsquo;s window-visibility implementation showed that macOS window occlusion contributes to whether a WebView is considered visible. The source\u0026rsquo;s \u003ccode\u003ewindowOcclusionDetectionEnabled\u003c/code\u003e setting defaults to enabled, with the private method:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-objc\" data-lang=\"objc\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nl\"\u003e_setWindowOcclusionDetectionEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThis supplied a direct intervention point for the second prediction: leave the window offscreen, but stop WebKit from treating the page as invisible because of occlusion.\u003c/p\u003e\n\u003cp\u003eGecko cannot directly obtain Safari\u0026rsquo;s internal WKWebView object. I therefore wrote a minimal WKWebView host, loaded the same local video test page, and recorded it externally with Gecko. The target changed to a controllable WebKit host, while the video input, page observations, and external window-recording approach remained.\u003c/p\u003e\n\u003cp\u003eThe setting appears in the matching release\u0026rsquo;s \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebKit/UIProcess/mac/WebViewImpl.h\"\u003eWebViewImpl.h\u003c/a\u003e and \u003ca href=\"https://github.com/WebKit/WebKit/blob/26695efdefc6076049ec50713a2c4e54b90b4d47/Source/WebKit/UIProcess/API/mac/WKWebViewMac.mm\"\u003eWKWebViewMac.mm\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eBoth runs used the same host program, each launched as a fresh process. Neither page performed Canvas drawing.\u003c/p\u003e\n\u003ch3 id=\"recording-three-different-window-states\"\u003eRecording three different window states\u003c/h3\u003e\n\u003cp\u003eThe minimal host recorded not only the page\u0026rsquo;s \u003ccode\u003evisibilityState\u003c/code\u003e, but also:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-swift\" data-lang=\"swift\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eocclusionState\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"bp\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(.\u003c/span\u003e\u003cspan class=\"n\"\u003evisible\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisVisible\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisMiniaturized\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThese values answer different questions. \u003ccode\u003eisVisible=true\u003c/code\u003e does not mean the window is currently in front of the user. After switching Spaces, a window can still be shown and not minimized while its occlusion-state visible bit is gone.\u003c/p\u003e\n\u003cp\u003eThe host also wrote page heartbeats, playback events, and visibility messages into the same native log. Combined with the window-state records, these showed both whether the window was actually offscreen and whether the page remained \u003ccode\u003evisible\u003c/code\u003e afterward.\u003c/p\u003e\n\u003cp\u003eHere is the key code that changed the setting in the custom host. \u003ccode\u003eObjCBool\u003c/code\u003e makes the method\u0026rsquo;s argument convention explicit, and a getter verifies the result. It does not use \u003ccode\u003eperform(_:with:)\u003c/code\u003e to pass a Boolean as an object:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-swift\" data-lang=\"swift\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003egetter\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eNSSelectorFromString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;_windowOcclusionDetectionEnabled\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esetter\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eNSSelectorFromString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;_setWindowOcclusionDetectionEnabled:\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eresponds\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003egetter\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eresponds\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esetter\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"bp\"\u003efatalError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;This WebKit build does not support the diagnostic setting\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003etypealias\u003c/span\u003e \u003cspan class=\"n\"\u003eRead\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003econvention\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ec\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003eAnyObject\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nb\"\u003eSelector\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eObjCBool\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003etypealias\u003c/span\u003e \u003cspan class=\"n\"\u003eWrite\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003econvention\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ec\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003eAnyObject\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nb\"\u003eSelector\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eObjCBool\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"nb\"\u003eVoid\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eread\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"bp\"\u003eunsafeBitCast\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emethod\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003egetter\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"n\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eRead\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"kc\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ewrite\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"bp\"\u003eunsafeBitCast\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emethod\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esetter\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"n\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eWrite\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"kc\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ewrite\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esetter\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"bp\"\u003eprecondition\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eread\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ewebView\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003egetter\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"n\"\u003eboolValue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cem\u003eFor the custom WKWebView diagnostic host only. This is a private API on an object inside the target process; it cannot configure Safari in another process.\u003c/em\u003e\u003c/p\u003e\n\u003ch3 id=\"result-occlusion-remained-but-page-hiding-disappeared\"\u003eResult: occlusion remained, but page hiding disappeared\u003c/h3\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eCondition\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eSystem window state\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003ePage state\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eRecorded content\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eA: default detection\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOccluded for about 28 seconds; not minimized\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eHidden for 28.037 seconds\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eBlank video\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eB: detection disabled\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOccluded for 38.082 seconds; not minimized\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eRemained visible during playback\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eUpdating video\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/safari-offscreen-video-black/wk-default.mp4.poster.webp\"\n        width=\"1080\"\n        height=\"842\"\n        style=\"--video-ratio: 1080 / 842\"\n    \u003e\n        \u003csource\n            src=\"/posts/safari-offscreen-video-black/wk-default.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003cp\u003e\u003cem\u003eDefault WKWebView detection: samples from inside the video stayed blank at approximately 16–39 seconds.\u003c/em\u003e\u003c/p\u003e\n\u003cfigure\u003e\u003cvideo\n        controls\n        playsinline\n        preload=\"none\"\n        poster=\"/posts/safari-offscreen-video-black/wk-occlusion-disabled.mp4.poster.webp\"\n        width=\"1080\"\n        height=\"840\"\n        style=\"--video-ratio: 1080 / 840\"\n    \u003e\n        \u003csource\n            src=\"/posts/safari-offscreen-video-black/wk-occlusion-disabled.mp4\"\n            type=\"video/mp4\"\n        /\u003e\n\n        您的浏览器不支持 HTML5 视频。\n    \u003c/video\u003e\n\n\u003c/figure\u003e\n\u003cp\u003e\u003cem\u003eDetection disabled: the video region kept changing at approximately 12–45 seconds. Native window logs established that the window was offscreen; page-hidden state was not used as proof of departure.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eAgain, I analyzed only the video\u0026rsquo;s interior. A had one distinct image across 46 samples; B had 66 distinct images across 66 samples.\u003c/p\u003e\n\u003cp\u003eThis showed that \u003cstrong\u003eunder this WKWebView configuration, converting window occlusion into page-hidden state was an important upstream causal factor in the missing video.\u003c/strong\u003e Disabling that conversion left the system\u0026rsquo;s occlusion judgment intact, yet the recorded video updated.\u003c/p\u003e\n\u003cp\u003eThis experiment alone did not establish Safari\u0026rsquo;s final backend action. The next step was to collect runtime evidence from Safari itself.\u003c/p\u003e\n\u003ch2 id=\"following-visibility-changes-through-safaris-media-processes\"\u003eFollowing visibility changes through Safari’s media processes\u003c/h2\u003e\n\u003cp\u003eBoth predictions matched the source-based explanation. I returned to Safari itself and inspected unified logs from the two recordings: did view invisibility become media invisibility for the same player and propagate to the GPU backend?\u003c/p\u003e\n\u003cp\u003eWebKit uses multiple processes, and media state can cross WebContent and GPU processes. Searching for \u003ccode\u003efalse\u003c/code\u003e by time alone was insufficient: other windows, old pages, or other players could be active at the same time.\u003c/p\u003e\n\u003cp\u003eI correlated Safari/WebContent/GPU process ownership, page identity, and the player identifier for each run. Reloading between A and B changed the player identifier, so each had to be followed separately.\u003c/p\u003e\n\u003ch3 id=\"safari-without-drawing\"\u003eSafari without drawing\u003c/h3\u003e\n\u003cp\u003eTaking the view\u0026rsquo;s transition to invisible as time zero, the actual log messages can be organized as follows:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e+0 ms   Safari / ViewState:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          view visibility state changed 1 -\u0026gt; 0\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e+1 ms   WebContent / HTMLMediaElement:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          visibilityStateChanged(ELEMENT_A) visible = 0\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e+1 ms   WebContent / MediaPlayerPrivateRemote:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          setPageIsVisible(PLAYER_A) false\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e+1 ms   GPU / RemoteMediaPlayerProxy:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          setPageIsVisible(PLAYER_A) false\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e+1 ms   GPU / MediaPlayerPrivateAVFoundation:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          setPageIsVisible(PLAYER_A) false\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eWhen I returned, the same player\u0026rsquo;s state propagated back to true, and the picture returned in the recording.\u003c/p\u003e\n\u003cp\u003eThis was no longer just an inference from a custom WKWebView: \u003cstrong\u003eactual Safari propagated media invisibility to its AVFoundation backend.\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"safari-with-a-single-canvas-draw\"\u003eSafari with a single Canvas draw\u003c/h3\u003e\n\u003cp\u003eIn the other comparison, the single Canvas draw occurred first. Safari\u0026rsquo;s view still subsequently became invisible, but the recorded video kept updating. During that offscreen interval, I did not observe the corresponding player becoming media-invisible or the backend receiving false.\u003c/p\u003e\n\u003cp\u003eMissing log messages cannot independently prove that a function never ran. Combined with the page remaining hidden, the single draw, and the changing recorded video, however, they supported the same distinction: \u003cstrong\u003eCanvas did not prevent the view from hiding; it changed the outcome of media-visibility propagation.\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"verifying-the-layer-hiding-behavior-in-the-installed-binary\"\u003eVerifying the layer-hiding behavior in the installed binary\u003c/h2\u003e\n\u003cp\u003eMatching a public release version is more reliable than reading main, but it does not establish instruction-for-instruction equivalence with Apple\u0026rsquo;s shipped binary.\u003c/p\u003e\n\u003cp\u003eI therefore made a read-only check: load the installed system WebCore in a temporary process of my own, then inspect \u003ccode\u003eMediaPlayerPrivateAVFoundationObjC::platformSetVisible(bool)\u003c/code\u003e with LLDB.\u003c/p\u003e\n\u003cp\u003eThe loaded WebCore was inside WebKit.framework, with its image coming from the dyld shared cache.\u003c/p\u003e\n\u003cp\u003eThe relevant disassembly, omitting absolute runtime addresses:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eldr x8, [x0, #0x1a8]\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ecbz x8, ...                     // Return if no video layer exists\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003emov x19, x1                     // Save the visibility argument\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e...\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eldr x0, [x20, #0x1a8]            // Video-layer object\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eeor w2, w19, #0x1                // Invert the Boolean\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ebl objc_msgSend$setHidden:        // Set the layer\u0026#39;s hidden property\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe installed function did set \u003ccode\u003ehidden\u003c/code\u003e according to visibility, matching the release source.\u003c/p\u003e\n\u003cp\u003eThe evidence now covered four different kinds of observation:\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eEvidence\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eQuestion answered\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eOriginal recordings and video-region samples\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWas content actually missing, and did it update after intervention?\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eCanvas and WKWebView interventions\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eDid changing media state or upstream occlusion handling change the result?\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eActual Safari cross-process logs\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eDid invisibility propagate to the player\u0026rsquo;s backend?\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eRelease source and installed machine code\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eWhat rule did the backend use to handle the video layer?\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eThe experiments showed that visibility interventions changed recording results. Runtime logs showed the invisible state reaching the media backend. Matching source and local machine code showed the backend hiding the video layer according to that state. Together, these established the mechanism in this case: Safari\u0026rsquo;s visibility policy suppressed video-layer presentation.\u003c/p\u003e\n\u003ch2 id=\"conclusion-safaris-visibility-policy-hides-the-offscreen-video-layer\"\u003eConclusion: Safari\u0026rsquo;s visibility policy hides the offscreen video layer\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eFor the tested non-DRM video scenario, the cause was this: switching the window to another Space made it occluded; WebKit hid the page; ordinary video became media-invisible; the AVFoundation backend hid its video layer; and external window capture consequently lacked the video content.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe sequence can be summarized as follows:\u003c/p\u003e\n\u003cp\u003e\u003cpicture\u003e\u003csource type=\"image/avif\" srcset=\"/posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_320x.avif 320w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_480x.avif 480w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_720x.avif 720w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_1080x.avif 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003csource type=\"image/webp\" srcset=\"/posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_320x.webp 320w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_480x.webp 480w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_720x.webp 720w, /posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png_1080x.webp 1080w\" sizes=\"auto, (min-width: 1080px) 1080px, calc(100vw - 32px)\"\u003e\u003cimg src=\"/posts/safari-offscreen-video-black/safari-black-screen-sequence-en.png\" alt=\"Safari offscreen video loss and recovery after returning\"\n         width=\"1080\" height=\"770\"\n         loading=\"lazy\" decoding=\"async\"\u003e\n  \u003c/picture\u003e\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eA mechanism diagram based on experiments, actual Safari logs, matching release source, and the installed implementation. Arrows show state propagation and content changes, not exact call latency. Video-layer actions come from source and machine-code checks, not a live property snapshot of the historical instance.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eSCK callbacks and file writing can continue, so an increasing frame count and a successfully saved file do not guarantee that the video region still contains video. Returning to the original Space restores visibility, the backend shows the video layer again, and the recording recovers.\u003c/p\u003e\n\u003cp\u003eA single Canvas draw and disabling WKWebView occlusion detection intervene at media visibility and page visibility respectively, preventing the hiding chain from continuing along its ordinary path. That is why both change the result.\u003c/p\u003e\n\u003cp\u003eThis conclusion is limited to the Safari/WebKit version and local non-DRM footage examined here, not every Safari video source.\u003c/p\u003e\n\u003ch2 id=\"what-can-gecko-control\"\u003eWhat can Gecko control?\u003c/h2\u003e\n\u003cp\u003eFinding an effective intervention does not mean Gecko can apply it to an arbitrary Safari window. These controls live inside the application being recorded.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003edrawImage(video)\u003c/code\u003e requires cooperation from the page or browser; it cannot be executed through an SCK window ID. Even with that cooperation, the experiments cover only the tested footage and hidden intervals lasting tens of seconds. Source changes, seeking, player recreation, and long recordings need separate validation.\u003c/p\u003e\n\u003cp\u003eThe private occlusion setting belongs to a WKWebView object in the target process. A custom host can call it; Gecko cannot configure another process\u0026rsquo;s Safari window the same way.\u003c/p\u003e\n\u003cp\u003ePulse can improve offscreen frame supply, but it cannot change Safari\u0026rsquo;s internal media visibility. The external screenshot experiments did not restore video either. The cause has been identified, but I have not found a general repair Gecko can enable directly without Safari\u0026rsquo;s cooperation.\u003c/p\u003e\n\u003cp\u003eThe reproduction materials have been submitted as \u003ca href=\"https://bugs.webkit.org/show_bug.cgi?id=331092\"\u003eWebKit #331092\u003c/a\u003e and added to Apple Feedback \u003cstrong\u003eFB25368989\u003c/strong\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-clue-came-from-another-browser\"\u003eThe clue came from another browser\u003c/h2\u003e\n\u003cp\u003eThe Safari investigation once stalled at “why does one Canvas draw restore the video?” Later, with Gecko\u0026rsquo;s basic functionality complete, regression testing showed that Chrome also turned black offscreen—even though early tests had worked. A Chrome launch command recovered from terminal history eventually redirected the investigation toward window occlusion and suggested how to investigate Safari\u0026rsquo;s source.\u003c/p\u003e\n\u003cp\u003eThis was the issue that consumed the most time and was hardest to advance during Gecko\u0026rsquo;s development. Several promising leads ended at another boundary. I could not find a complete explanation of this behavior in the public material I consulted, so I have documented the route from symptoms and controlled experiments to source verification in the hope that it gives others a useful starting point.\u003c/p\u003e\n\u003cp\u003eThe next article, \u003ca href=\"/en/posts/chrome-offscreen-video-black/\"\u003ethe Chrome investigation\u003c/a\u003e, follows that contradiction: why did offscreen recording work early on, only to turn black later?\u003c/p\u003e\n","date_published":"2026-11-13T15:05:08+08:00","tags":["Gecko","macOS","ScreenCaptureKit","Safari","WebKit"]}]}