Repository navigation
Description
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.ccpicks 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.ccsubtracts 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
- 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). - Page with a
<video>at 30 fps (or at 60 fps plus a CSS animation on the main thread). - Watch the texture: the video stutters. Same page with
external_begin_frame_enabled = false: smooth. - Count the frames presented per video frame with
requestVideoFrameCallback: holes and doubles, no counter
on the CEF side shows it.
Activity
osr: Add SendExternalBeginFrameWithTiming (see chromiumembedded#4311)
aisuvakov commentedon Oct 14, 2026
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 withgetVideoPlaybackQuality():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 thebegin_frame_pending_drop described here.Happy to test #4312 on 50 Hz SDI (DeckLink) once it lands on a branch with builds.
magreenblatt commentedon Oct 14, 2026
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?
kin4stat commentedon Oct 15, 2026
@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 thebase::TimeTickstimebase and the interval it actually achieves;0falls back toNow()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
requestAnimationFrametimestamps. "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.Now()on the UI thread, 16.67 ms intervalframe_timestillNow()on the UI threadframe_timeIn 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_timeplus a multiple ofinterval, so a jitteredframe_timemoves every one of them.deadlinewas 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 fromframe_time + interval. So in our case setting the deadline does not stand in for theframe_timepart. 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
IssueExternalBeginFrameis 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 withmojo::WrapCallbackWithDropHandler. #4312 resetsin_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
kin4stat commentedon Oct 15, 2026
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 hasSendExternalBeginFrameAt(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 bybegin_frame_pending_and issues it at the acknowledgement if less than half an interval has passed, as #4312 does.Configs:
Now()on the UI thread, 16.67 ms, null deadlineNow(), the real interval, deadline =frame_time + intervalframe_timeq: with the one-deep queuePage: VP9 1280x720 test videos (WebM, 1 ms timestamps). For each video frame,
requestVideoFrameCallbackexpectedDisplayTimegives 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 (mediaTimevspresentedFrames). 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.What this says, in our setup:
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.
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.
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%).
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.
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.
super-ienien commentedon Oct 15, 2026
@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.
osr: Fix external BeginFrame timing, optional tick age (see chromiumeβ¦
super-ienien commentedon Oct 15, 2026
@magreenblatt Thanks for the review. I agree:
SendExternalBeginFrameWithTimingmakes 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: keepSendExternalBeginFrame()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_ratefor the first tick) and the deadline isframe_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.
SendExternalBeginFrame(), your proposal<video>25 fps frame holds β 2 fields (of ~22 000)droppedVideoFramesHow to read the rows:
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 withrequestVideoFrameCallback. A frame held for 1 or 3 fields is a visible judder. About 22 000 video frames per arm.droppedVideoFrames:getVideoPlaybackQuality().droppedVideoFramesof 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 perSendExternalBeginFramecall, 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?super-ienien commentedon Oct 16, 2026
Hi !
Pushed the follow-up revision to #4312: it handles a dropped reply.
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.
aisuvakov commentedon Oct 18, 2026
Follow-up with measurements of what actually reaches the output on a 50 Hz broadcast setup, including real graphics pages, not only test clips. We now ship the interval change in production (our own CEF 151 build, Windows).
Setup. Windows 11. Windowless browser,
external_begin_frame_enabled,windowless_frame_rate= output rate, oneSendExternalBeginFrame()per output tick from the output clock thread, shared textures. Output goes out over NDI and is recorded on the same LAN; frame numbers are burned into the clips, so for every output frame we know which video frame was shown. Two machines: i5-12400F + RTX 3060, and a laptop i5-7300HQ + GTX 1050.Builds:
1. Video, test page, 1080p50 (RTX 3060)
Video frame step between consecutive output frames, 500 output frames. Ideal: 1 for 50 fps,
1,1,1,1,2for 60 fps, 0/1 alternating for 25 fps.<video><video>With stock 151 a 50 fps video advances on roughly every other output frame in every display mode. The interval change fixes it for visible
<video>and for a canvas redrawn on rAF.2. Real page: video backgrounds of lower thirds, 1080p50 (RTX 3060)
Here the
<video>elements aredisplay: noneand drawn into canvases. Share of output frames where the background did not advance by exactly one frame:The middle row was the surprise: it is enough to have a
requestVideoFrameCallbackpending on the hidden video (we used it to keep the element "alive" between plays) β the video then goes through the compositor frame-submission path (VideoFrameSubmitter::OnBeginFrame,VideoFramesDroppedin traces) and with the interval build it advances on every other output frame. Without a pending rVFC the canvas path is clean. We now draw on rAF and only register rVFC while the video is paused.The same page in vMix (CEF 86, its own clock) and OBS (its own CEF) gives 0.4 % and 0.7 %, so the page change does not hurt hosts we do not control.
One more effect, not CEF-specific (present in vMix and OBS too): a 50 fps clip started on a 50 Hz page lands at a random phase relative to the begin frames. When the phase is near the middle of a frame (0.45β0.69 of a frame period in our measurements),
VideoRendererAlgorithm::FindBestFrameByCoveragesees two candidates with nearly equal coverage on every tick and alternates, giving 0-2-0-2 for the whole clip. We detect the phase from the first three frames and nudge it by half a frame (playbackRate = 1.5for 20 ms).3. Title animation (no video), whole-frame repeats during the 1.5 s intro
Output frames identical to the previous one while a GSAP animation is moving. The client keeps a 2-deep queue of finished paints and outputs the oldest (40 ms extra latency):
Without the queue the laptop was worse with the interval build in the middle of the intro:
Display::DrawAndSwapin the GPU process occasionally took 19β25 ms (never above 9 ms in stock). This may be my non-official, non-PGO build rather than the patch; I have not separated the two. The queue is a different thing from the one-deep tick queue in #4312: that one keeps a refused tick, ours buffers finished paints.Notes for anyone building 151 non-official on Windows
cef/tools/gn_args.pyforcesenable_backup_ref_ptr_instance_tracer=truefor non-official Windows builds, andGN_DEFINESdoes not override it. Withdcheck_always_on=falsethis deadlocks inCefInitializein about 1 of 3β6 cold starts (base::internal::InstanceTracer::TraceImpl/UntraceImpl, astd::mutexwith allocation inside). After disabling it ingn_args.py: 20 of 20 cold starts fine. After the flag change ninja did not rebuild the Dawn objects and the link failed with undefinedInstanceTracersymbols; deletingout/Release_GN_x64/obj/third_party/dawnfixed it.0x80000003inpartition_alloc::ScopedDisallowAllocationsfrombase::LockMetricsRecorder;--disable-features=RecordLockAcquisitionTimeavoids it.@super-ienien happy to run the same harness on the reshaped #4312 once it is on the branch.
kin4stat commentedon Oct 18, 2026
@aisuvakov thanks, this is very useful, especially the rVFC finding.
On our side (the game overlay setup from my comments above: windowless browsers composited into a game frame, no video), we haven't noticed a visible difference in production from the interval/deadline change on its own. Two other things cause us more trouble:
We plan to look into both more closely, put the results together and open separate issues for them rather than mixing them into this one.
super-ienien commentedon Oct 18, 2026
Hi guys,
Thanks, I just had a quick look into your responses before going to sleep.
@aisuvakov Thanks, this is very useful. The updated version of #4312 is already on the branch (two commits, 6e55867 and becbdff). The patch is at https://github.com/chromiumembedded/cef/pull/4312.diff. It is based on master, so it may need some adjustmentsfor 151.
The requestVideoFrameCallback rows are the ones I would most like to see with it. Here is what I think happens. CEF accepts only one external BeginFrame at a time: while one is pending, the next SendExternalBeginFrame() call is ignored. When a BeginFrame ends up drawing nothing new, viz only answers it about one interval after it was sent. With the default 16.67 ms interval, that answer comes before your next 20 ms tick. With a 20 ms interval, it comes at the same time as the next tick, so about every other call is ignored, and the video only moves on every other output frame. #4312 does not drop that call: it keeps it and sends it as soon as the answer arrives. So these rows should go back to one video frame per output frame, even without the new optional argument. If you can count the ignored calls in your current build, that would confirm it.
In other words, the interval change alone fixes a visible video element, but more ticks are lost, and pages that use requestVideoFrameCallback get worse. What fixes both is to stop losing those ticks: when SendExternalBeginFrame() is called while the previous BeginFrame is still pending, #4312 sends the new one as soon as the previous one is answered, instead of discarding it.
@kin4stat On your second point: we have built the same thing on 156, also named CefBrowserHost::ReleaseAcceleratedPaint. The client keeps the shared texture after OnAcceleratedPaint returns, and gives it back when it is done with it. The number of textures of the CEF shared texture pool can be set in CefWindowInfo. If the client still holds all of them when a new frame is ready, that frame is not delivered: CEF never waits for the client. Compared to blocking CEF's UI thread in OnAcceleratedPaint until the copy is done, it halved the UI thread load on our side. Happy to compare notes on that separate issue.
On your first point: this is what the optional argument of SendExternalBeginFrame in #4312 is for. The caller says how long ago its tick happened, and CEF dates the frame on the tick rather than on the moment of the call. The interval is then taken from the time between the last ticks (median of the last five). Tell us if a game loop needs to pass the interval for each frame instead.
magreenblatt commentedon Oct 18, 2026
In PR #4317 I've implemented the baseline timing fix (no new public API) with integration test coverage. Please use this PR as a basis for adding additional test coverage for new/proposed API changes and for proving that those new APIs provide better timing, reliability and/or performance numbers compared to this baseline. See the PR description for complete usage details and expectations.
netshade commentedon Oct 22, 2026
Just adding a note to say that
OnExternalBeginFrameCompleteis very interesting to me. I drive windowless browsers withSendExternalBeginFrameto capture screenshots and video. After an action, such as a load, a script or an input event, I need to know when a frame that reflects it has been painted. Today I can't tell "drawn" from "nothing to draw" from "dropped", so it's inferred by absence: a doublerequestAnimationFramein the page, then repaint and poll for a freshOnPaintuntil a timeout. On a blank page, that's about 167 ms of a 200 ms capture.A per-call outcome (drawn / no damage / dropped) would let me wait for exactly one acknowledged frame instead of polling, and stop waiting at once when no paint is coming. Happy to measure this on top of #4317 if the callback lands.
osr: Use configured timing for external BeginFrames (see #4311)