Skip to content

osr: External BeginFrames carry the time of the call, no deadline and a fixed 60 Hz interval #4311

Open

Description

@super-ienien

Describe the issue

With CefWindowInfo::external_begin_frame_enabled, CefBrowserHost::SendExternalBeginFrame() builds the
viz::BeginFrameArgs with frame_time = base::TimeTicks::Now() (the time of the call, so the wake-up jitter of
the client's tick thread), a null deadline and interval = viz::BeginFrameArgs::DefaultInterval() (16.67 ms
whatever the external clock is). Chromium uses those values as they are:

  • cc/layers/video_frame_provider_client_impl.cc picks the video frame to show in
    [frame_time + interval, frame_time + 2 * interval]: wrong by a sixth on a 50 Hz clock, and sliding with the
    jitter of the call;
  • cc/scheduler/scheduler.cc subtracts its draw estimate from the deadline: null, it lands in the past and the
    compositor draws without waiting for the main thread's frame, which lands sometimes before, sometimes after;
  • viz (external_begin_frame_source_mojo.cc, begin_frame_source.cc) forwards all three values unchanged.

Observable effect, windowless browser clocked by a hardware tick (SDI card or vsync) on Windows: a 30 fps
<video> on a 60 Hz tick stutters, and a 60 fps video next to a CSS animation stutters as soon as the main
thread works; the same pages are smooth without external BeginFrames. Counters show nothing (one paint per
tick), only the content does.

Also, viz accepts a single IssueExternalBeginFrame in flight per controller: a tick that arrives while the
previous one is pending is dropped by CefRenderWidgetHostViewOSR (begin_frame_pending_), which loses one
tick in two for a video whose frames take a tick to acknowledge.

Proposed change (pull request to follow)

Add CefBrowserHost::SendExternalBeginFrameWithTiming(frame_time_offset_us, interval_us, deadline_offset_us):
the age of the tick at the time of the call (an offset, since the TimeTicks base is not exposed by the C API),
the period of the external clock, and the deadline offset (<= 0 = frame_time + interval, what Chromium's own
BeginFrame sources use). Rewrite the external BeginFrame path on the model of ui::ExternalBeginFrameAdapter,
the platform BeginFrame source Chromium added for Wayland: sequence numbers from
viz::BeginFrameSource::BeginFrameArgsGenerator, wait_for_all_frame_sinks = false, vsync parameters following
the interval, and a tick that arrives while another is in flight kept one deep and issued at the acknowledgement
if its interval is not half over. SendExternalBeginFrame() keeps its behavior except that the interval follows
windowless_frame_rate and the deadline equals the interval. Experimental API.

A natural follow-up, once this is settled: report the outcome of each external BeginFrame to the client
(CefRenderHandler::OnExternalBeginFrameComplete, drawn / nothing to draw / dropped, from the BeginFrameAck
that viz already returns to the controller, as headless returns hasDamage to DevTools). It lets a client know
that nothing will be painted for a tick instead of inferring it from a timeout. I have it working on top of this
change and can submit it separately.

Version

CEF master a875f407 (Chromium 156.0.8078.0 compatibility); measured on a build of branch 8078
(156.0.7+g47dbc1d+chromium-156.0.8078.17), Windows 11, MSVC 2022 17.13 / SDK 10.0.28000, NVIDIA RTX 4060 and
Quadro P4000, D3D12 shared textures.

Steps to reproduce

  1. Windowless browser with external_begin_frame_enabled, shared_texture_enabled, windowless_frame_rate = 60,
    SendExternalBeginFrame() from a thread woken by the monitor vsync (IDXGIOutput::WaitForVBlank).
  2. Page with a <video> at 30 fps (or at 60 fps plus a CSS animation on the main thread).
  3. Watch the texture: the video stutters. Same page with external_begin_frame_enabled = false: smooth.
  4. Count the frames presented per video frame with requestVideoFrameCallback: holes and doubles, no counter
    on the CEF side shows it.

Activity

  1. aisuvakov commented on Oct 14, 2026

    @aisuvakov

    Confirming this on a 50 Hz external clock (broadcast playout, 1080p50 → tick every 20 ms; CEF 151.3.18, Windows 11 / RTX 3060 and macOS arm64). Single <video> test page, counted with getVideoPlaybackQuality():

    video tick dropped
    50 fps 50 Hz 12–14 % (Windows), up to 54 % (macOS)
    25 fps 50 Hz 0 or exactly every 8th frame, depending on phase
    any 25 Hz (40 ms tick) 43–73 %
    60 fps 50 Hz 16.9 % (= the unavoidable 1/6, evenly spread)
    same pages Chrome, 60 Hz vsync 0

    This matches the analysis: with interval = DefaultInterval() the frame-selection window is 16.67 ms per 20 ms tick, leaving a 3.3 ms gap, and frames timestamped in the gap are dropped. Workarounds on the page side (canvas copy, display:none, etc.) don't help. Under load we also see the begin_frame_pending_ drop described here.

    Happy to test #4312 on 50 Hz SDI (DeckLink) once it lands on a branch with builds.

  2. magreenblatt commented on Oct 14, 2026

    @magreenblatt
    Collaborator

    Using windowless_frame_rate for the existing SendExternalBeginFrame method, along with setting deadline = frame_time + interval, seems reasonable. It’s less clear whether a new timing API is needed. Can you provide measurements showing the remaining timing jitter and its impact on video playback after those changes?

  3. kin4stat commented on Oct 15, 2026

    @kin4stat
    Contributor

    @magreenblatt
    Some numbers from our side on the timing question.

    We run a CEF fork (Chromium 150, branch 7871) with an equivalent API, SendExternalBeginFrameAt(frame_time_us, interval_us): the client passes the tick's own timestamp on the base::TimeTicks timebase and the interval it actually achieves; 0 falls back to Now() on the UI thread and 16.67 ms. Our host is a game loop rather than a hardware clock, and we measured animation and scrolling rather than video, but the A/B isolates the same two fields.

    Setup: Windows 11, AMD RX 9070 XT, two windowless browsers driven at 160 Hz by a pump thread on a high-resolution waitable timer, shared textures with a non-blocking paint callback, the client compositing once per tick on its own thread, and the page scrolling while it records requestAnimationFrame timestamps. "Tick time" is the grid boundary the pump has just crossed, independent of when its thread woke up. Skip is the share of rAF callbacks that arrive two or more ticks after the previous one; σ noise is the deviation of the rAF interval from a whole number of ticks, i.e. the timestamp jitter CSS/WAAPI animations see.

    BeginFrame args skip σ noise avg fps
    stock: Now() on the UI thread, 16.67 ms interval 64.8% 0.29 ms 99.7
    real interval, frame_time still Now() on the UI thread 23.0% 0.29 ms 142.4
    real interval and the tick's own frame_time 7.0% 0.06 ms 153.7

    In this workload the interval fix alone removes most of the damage, and passing the tick's own time then takes skip from 23.0% to 7.0% and cuts the timestamp jitter about five-fold. The two compound rather than add: the deadlines cc derives under load (LATE, WAIT_FOR_SCROLL) and Blink's idle period are all frame_time plus a multiple of interval, so a jittered frame_time moves every one of them.

    deadline was null in all three rows. We also swept real deadline values and saw no measurable change here: under load the scheduler lands in LATE or WAIT_FOR_SCROLL, which compute their own deadline from frame_time + interval. So in our case setting the deadline does not stand in for the frame_time part. We have not measured video.

    On the one-in-flight gate: the acknowledgement round trip was 200-300 µs against a 6.25 ms tick, and the gate refused 0-7 ticks per 600. We tried keeping a refused tick and issuing it on the acknowledgement, as #4312 does, and also pipelining two BeginFrames; neither changed anything measurable in this workload. It probably matters only when the acknowledgement takes a large part of the tick, as in the video case above.

    One more thing #4312 touches: in the current code, if the reply callback passed to IssueExternalBeginFrame is dropped without running (we hit this on context loss and restore), begin_frame_pending_ is never cleared and every later tick is dropped for good. We fixed it with mojo::WrapCallbackWithDropHandler. #4312 resets in_flight_ when the controller client is recreated; it is worth confirming that every path that drops the reply also recreates it.

    ^^^
    Generated by Claude from my manual measurements and conclusions after running the tests with different approaches on our workload, sorry for AI text

  4. kin4stat commented on Oct 15, 2026

    @kin4stat
    Contributor

    Just retested all the configs

    Measurements for the question above. Fork of CEF on Chromium 150 (branch 7871), Windows 11, AMD RX 9070 XT, one windowless browser with shared textures, external BeginFrames from a thread on a high-resolution waitable timer (so Now() carries its wake-up jitter plus the hop to the UI thread). Our fork has SendExternalBeginFrameAt(frame_time_us, interval_us); for this we added two test switches: deadline = frame_time + interval, and a one-deep queue that keeps a tick refused by begin_frame_pending_ and issues it at the acknowledgement if less than half an interval has passed, as #4312 does.

    Configs:

    • A: stock, Now() on the UI thread, 16.67 ms, null deadline
    • B: what you proposed, Now(), the real interval, deadline = frame_time + interval
    • C: B with the tick's own frame_time
    • D: C without the deadline
    • q: with the one-deep queue

    Page: VP9 1280x720 test videos (WebM, 1 ms timestamps). For each video frame, requestVideoFrameCallback expectedDisplayTime gives how many ticks it stayed on screen; "on cadence" is the share shown for exactly hz/fps ticks. Holes are frames the media clock passed but that were never presented (mediaTime vs presentedFrames). rAF skip is the share of rAF callbacks two or more ticks apart; σ noise is the deviation of the rAF interval from whole ticks. Gate drops are ticks refused while one was pending. 30 s per run after warm-up, 3 runs per cell, mean (min-max). "css" adds a non-composited CSS animation (left) and 6 ms of JS per frame.

    scenario config on cadence holes / 30 s rAF skip σ noise gate drops
    30 fps @ 60 Hz A 2.3% 51 (0-76) 35.5% 0.41 ms 26.2%
    B 2.8% 30 (3-83) 34.6% 0.45 ms 26.0%
    Bq 99.1% 1 (0-2) 0.1% 0.71 ms 0.4%
    Cq 100.0% 0 0.0% 0.06 ms 0.4%
    25 fps @ 50 Hz A 77.4% (66.7-83.0) 67 (53-93) 0.2% 0.49 ms 0.5%
    B 7.1% 123 (82-143) 39.5% 0.47 ms 28.6%
    Bq 99.6% 0 0.1% 0.52 ms 0.3%
    Cq 100.0% 0 0.0% 0.06 ms 0.3%
    60 fps + css @ 60 Hz A 99.8% 3 (0-5) 40.3% (0.0-66.9) 0.37 ms 0.5%
    B 99.3% 38 (1-106) 0.1% 0.42 ms 0.3%
    Bq 99.5% 35 (0-105) 0.0% 0.39 ms 0.2%
    Cq 100.0% 0 0.0% 0.06 ms 0.2%
    50 fps + css @ 50 Hz A 80.2% 188 (186-189) 29.2% (7.4-61.0) 0.36 ms 0.5%
    B 100.0% 0 0.0% 0.36 ms 0.2%
    Bq 99.9% 0 0.0% 0.44 ms 0.2%
    Cq 100.0% 0 0.0% 0.06 ms 0.2%

    What this says, in our setup:

    1. The interval change is needed whenever the clock is not 60 Hz: at 50 Hz the stock 16.67 ms leaves about 185 of 1500 frames of a 50 fps video unpresented, with or without the queue, and a 25 fps video lands between 67% and 100% on cadence depending on phase.

    2. The deadline change is needed for main-thread content under load. With a null deadline, rAF skip under the css load is bimodal across runs: A 0-67%, D 0-90% at 60 Hz, D 0-13% at 50 Hz. With the deadline it is 0.0-0.2% in every run. Video frames themselves are not affected.

    3. Those two changes alone do not fix video when some ticks have nothing new to draw: with B, the 30 and 25 fps videos are on cadence 3% and 7% of the time, because the one-in-flight gate refuses 26-29% of the ticks. When a tick produces no new frame, its acknowledgement comes back about one declared interval later, so it races the next tick. That is visible directly at 50 Hz: with the stock 16.67 ms interval those acknowledgements arrive before the 20 ms tick and the gate refuses only 0.5%. A one-deep queue fixes it (99.1-99.6%).

    4. With interval, deadline and queue in place, what passing the tick's own time still changes: σ of the rAF timestamps drops from 0.4-0.7 ms to 0.06 ms, and video goes from 99.1-99.9% to 100.0% on cadence. At 60 fps under load single runs showed about 100 holes in B and in D but never in C; three runs are not enough to attribute that. So for video at 50/60 Hz the queue matters far more than the tick time. At high rates the tick time matters more: in our game-loop workload at 160 Hz it took rAF skip from 23% to 7% with an honest interval.

    5. Separately: in 3 of 96 runs the view froze at startup. The acknowledgement for the fifth BeginFrame never arrived (the callback was not destroyed, it simply was not run), and with one in flight nothing was drawn for the rest of the run, with or without the queue. We have not root-caused it, but a gate that only reopens on an acknowledgement or on a new controller can wait forever.

  5. super-ienien commented on Oct 15, 2026

    @super-ienien
    Author

    @aisuvakov Thank you for the measurements, they confirm the analysis. Your future measurements will help confirm that the fix proposed in this PR works on your setup. I will ping you once the reshaped commit is on the PR branch.

    @kin4stat Thank you, this is exactly the kind of A/B that was missing, and your numbers line up with ours.

    On the two code points:

    Dropped reply: agreed. The PR only reopens the gate on the acknowledgement or when the controller client is recreated. The next revision wraps the reply with mojo::WrapCallbackWithDropHandler, so the gate also reopens when the reply is dropped.
    Acknowledgement that never comes: a client-side watchdog cannot re-issue, viz reports a bad message on any overlapping IssueExternalBeginFrame. So the gate can only reopen on an acknowledgement or a new controller. We have not seen the startup freeze (about 110 two-minute runs), but the next revision logs a warning after a few intervals without an acknowledgement, so that the case is at least visible.

  6. super-ienien commented on Oct 15, 2026

    @super-ienien
    Author

    @magreenblatt Thanks for the review. I agree: SendExternalBeginFrameWithTiming makes the API more complex, and its arguments are hard to use.

    After measuring your proposal (interval = 1 / windowless_frame_rate, deadline = frame_time + interval), the API can be simplified: keep SendExternalBeginFrame() and add a single optional argument, the age of the tick at the time of the call. This is the only value that cannot be derived by CEF, and the only one needed for a smooth output under load. The interval is derived from the successive tick times (median of the last few, windowless_frame_rate for the first tick) and the deadline is frame_time + interval. Callers that do not pass the age get the current behaviour, with the interval and deadline fixes.

    Measurements. DeckLink SDI output, 1080i50, external BeginFrame driven by the card's frame callback, 6 CPU burner processes (about 62 % system CPU), 8 interleaved passes of 120 s per arm, same run, same binary. Idle, the arms are indistinguishable.

    under CPU load, sums over 8 passes SendExternalBeginFrame(), your proposal with the tick age (card's hardware clock)
    CSS animation steps ≠ 1 (rAF time index, of ~46 000) 29 7
    <video> 25 fps frame holds ≠ 2 fields (of ~22 000) 32 20
    missing / repeated fields at the SDI output 16 / 35 0 / 8
    droppedVideoFrames 2 0

    How to read the rows:

    • CSS animation steps ≠ 1: the page runs a CSS animation and, at each requestAnimationFrame, records which tick it is on (rAF timestamp divided by the tick period). Consecutive callbacks should advance by exactly one tick; a step of 0 or 2 is a visible hiccup. About 46 000 callbacks per arm over the 8 passes.
    • <video> frame holds ≠ 2 fields: a 25 fps video on a 50 Hz (1080i50) output should show each frame for exactly 2 fields, measured with requestVideoFrameCallback. A frame held for 1 or 3 fields is a visible judder. About 22 000 video frames per arm.
    • missing / repeated fields at the SDI output: the SDI output is captured back and every field carries a counter drawn by the page. A missing field is a counter that skips one, a repeated field a counter that stays. This is what the viewer actually sees.
    • droppedVideoFrames: getVideoPlaybackQuality().droppedVideoFrames of the <video> element.

    The remaining jitter with the time of the call is the wake-up jitter of the client's tick thread (up to a full field period under load), and it goes straight into the animation timeline. A client-side filtered grid gives the same result as the hardware time (8 / 16 / 0 / 13), so the gain comes from an accurate tick date, not from any filtering. kin4stat's measurements above, on a different setup, show the same thing.

    A side benefit: since the interval follows the tick dates, a variable refresh rate is supported. I have no device to test it, but on paper it works; once the direction of the fix is agreed, I can set up a test for it.

    A follow-up revision will handle a reply dropped before the frame sink exists or on GPU process loss

    One more thing: I also have a proposal for a completion callback, CefRenderHandler::OnExternalBeginFrameComplete, one callback per SendExternalBeginFrame call, saying whether the frame was drawn, not drawn, or dropped. Today the application has to guess the state of the viz compositor. Do you think it is relevant? If so, should it go in this PR or in a new one?

  7. super-ienien commented on Oct 16, 2026

    @super-ienien
    Author

    Hi !

    Pushed the follow-up revision to #4312: it handles a dropped reply.

    • The gate now follows the reply callback itself: it is wrapped with mojo::WrapCallbackWithDropHandler (thanks @kin4stat), so it reopens on the acknowledgement or when the reply is destroyed without running.
    • The gate is no longer reset when the controller client is recreated. ui::Compositor keeps a tick pending until its frame sink exists and issues it right after the client is created. Resetting there could let a second BeginFrame overlap it.
    • A warning is logged when a tick stays unacknowledged for more than one second, so a stall like the startup freeze above is at least visible. @magreenblatt May be you won't like a new warning. I can sweep it if you prefer.

    Tested by killing the GPU process during playback (Windows, D3D12, DeckLink 1080i50, 3 kills per binary). BeginFrames now resume about 0.2 s after the GPU process restarts. Before this change, they stayed blocked until the client was recreated: 38 to 71 s
    of frozen output. 20 cold starts were clean, and the timing measurements above did not regress.

    Hope you'll find the SendExternalBeginFrame with the new optional argument interesting because it's the only way I found for us to get CEF compose in sync with an external hardware clock like the ones that drives professionnal video SDI cards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions