feat(cef): adapt osr frame rate to monitor
- commit
- 45b07b3ef353afecb6b97ff9c9204c46bf48ade4
- author
- brice · · 2026-05-20 05:55 UTC
- parent
- f52de71
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +355 -54
M README.md +1 -1
| @@ -101,7 +101,7 @@ CEF_DIR=/custom/path dumber browse | |||
| 101 | 101 | ||
| 102 | 102 | Then set `engine.type = "cef"` in your config. | |
| 103 | 103 | ||
| 104 | - | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | |
| 104 | + | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. CEF OSR frame rate adapts to the active Wayland monitor refresh rate by default, capped by `engine.cef.windowless_frame_rate_max = 240`; set `engine.cef.windowless_frame_rate` to a positive value for an explicit fixed cap. | |
| 105 | 105 | ||
| 106 | 106 | ## Keyboard-Driven | |
| 107 | 107 | ||
| @@ -101,7 +101,7 @@ CEF_DIR=/custom/path dumber browse | |||||
| 101 | 101 | ||||
| 102 | Then set `engine.type = "cef"` in your config. | 102 | Then set `engine.type = "cef"` in your config. | ||
| 103 | 103 | ||||
| 104 | - | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | 104 | + | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. CEF OSR frame rate adapts to the active Wayland monitor refresh rate by default, capped by `engine.cef.windowless_frame_rate_max = 240`; set `engine.cef.windowless_frame_rate` to a positive value for an explicit fixed cap. |
| 105 | 105 | ||||
| 106 | ## Keyboard-Driven | 106 | ## Keyboard-Driven | ||
| 107 | 107 | ||||
M docs/config/index.md +3 -0
| @@ -190,6 +190,9 @@ border = "#363636" | |||
| 190 | 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | |
| 191 | 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | |
| 192 | 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | |
| 193 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Adapt CEF OSR FPS to the active Wayland monitor when `windowless_frame_rate = 0` | | |
| 194 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 lets adaptive mode choose | | |
| 195 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | Hard cap for adaptive CEF OSR FPS | | |
| 193 | 196 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | |
| 194 | 197 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | |
| 195 | 198 | ||
| @@ -190,6 +190,9 @@ border = "#363636" | |||||
| 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | ||
| 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | ||
| 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | ||
| 193 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Adapt CEF OSR FPS to the active Wayland monitor when `windowless_frame_rate = 0` | | |||
| 194 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 lets adaptive mode choose | | |||
| 195 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | Hard cap for adaptive CEF OSR FPS | | |||
| 193 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | 196 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | ||
| 194 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | 197 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | ||
| 195 | 198 | ||||
M docs/reference/configuration.md +3 -0
| @@ -60,6 +60,9 @@ | |||
| 60 | 60 | | `rendering.sample_memory` | bool | `false` | | | |
| 61 | 61 | | `rendering.debug_frames` | bool | `false` | | | |
| 62 | 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | |
| 63 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | | | |
| 64 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |
| 65 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | | |
| 63 | 66 | | `default_ui_scale` | float | `1.0` | > 0 | | |
| 64 | 67 | | `default_webpage_zoom` | float | `1.2` | > 0 | | |
| 65 | 68 | | `workspace.new_pane_url` | string | `about:blank` | | | |
| @@ -60,6 +60,9 @@ | |||||
| 60 | | `rendering.sample_memory` | bool | `false` | | | 60 | | `rendering.sample_memory` | bool | `false` | | | ||
| 61 | | `rendering.debug_frames` | bool | `false` | | | 61 | | `rendering.debug_frames` | bool | `false` | | | ||
| 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | ||
| 63 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | | | |||
| 64 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |||
| 65 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | | |||
| 63 | | `default_ui_scale` | float | `1.0` | > 0 | | 66 | | `default_ui_scale` | float | `1.0` | > 0 | | ||
| 64 | | `default_webpage_zoom` | float | `1.2` | > 0 | | 67 | | `default_webpage_zoom` | float | `1.2` | > 0 | | ||
| 65 | | `workspace.new_pane_url` | string | `about:blank` | | | 68 | | `workspace.new_pane_url` | string | `about:blank` | | | ||
M go.mod +1 -1
| @@ -7,7 +7,7 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | github.com/bnema/purego-cef v0.13.0 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.3.0 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.4.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.3 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | 13 | github.com/bnema/puregotk v0.5.2 | |
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | github.com/bnema/purego-cef v0.13.0 | 9 | github.com/bnema/purego-cef v0.13.0 | ||
| 10 | - | github.com/bnema/purego-cef2gtk v0.3.0 | 10 | + | github.com/bnema/purego-cef2gtk v0.4.0 |
| 11 | github.com/bnema/purego-pipewire v0.1.3 | 11 | github.com/bnema/purego-pipewire v0.1.3 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | github.com/bnema/puregotk v0.5.2 | 13 | github.com/bnema/puregotk v0.5.2 | ||
M go.sum +2 -2 generated file: collapsed
M internal/bootstrap/engine.go +9 -7
| @@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 77 | 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 78 | 78 | } | |
| 79 | 79 | cefCfg := cef.RuntimeConfig{ | |
| 80 | - | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 81 | - | LogFile: cfg.Engine.CEF.LogFile, | |
| 82 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 83 | - | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | |
| 84 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 85 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 86 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 80 | + | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 81 | + | LogFile: cfg.Engine.CEF.LogFile, | |
| 82 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 83 | + | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | |
| 84 | + | AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(), | |
| 85 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 86 | + | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), | |
| 87 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 88 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 87 | 89 | } | |
| 88 | 90 | deps := cef.EngineDependencies{ | |
| 89 | 91 | RegisterHandlers: handlers.RegisterAll, | |
| @@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 78 | } | 78 | } | ||
| 79 | cefCfg := cef.RuntimeConfig{ | 79 | cefCfg := cef.RuntimeConfig{ | ||
| 80 | - | CEFDir: cfg.Engine.CEF.CEFDir, | 80 | + | CEFDir: cfg.Engine.CEF.CEFDir, |
| 81 | - | LogFile: cfg.Engine.CEF.LogFile, | 81 | + | LogFile: cfg.Engine.CEF.LogFile, |
| 82 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | 82 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, |
| 83 | - | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | 83 | + | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), |
| 84 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | 84 | + | AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(), |
| 85 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | 85 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, |
| 86 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | 86 | + | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), |
| 87 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |||
| 88 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |||
| 87 | } | 89 | } | ||
| 88 | deps := cef.EngineDependencies{ | 90 | deps := cef.EngineDependencies{ | ||
| 89 | RegisterHandlers: handlers.RegisterAll, | 91 | RegisterHandlers: handlers.RegisterAll, | ||
A internal/infrastructure/cef/adaptive_frame_rate.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const adaptiveFrameRatePollIntervalMS = 1000 | |
| 14 | + | ||
| 15 | + | func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 { | |
| 16 | + | return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{ | |
| 17 | + | DefaultFPS: fallbackFPS, | |
| 18 | + | MinFPS: fallbackFPS, | |
| 19 | + | MaxFPS: maxFPS, | |
| 20 | + | }) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | |
| 24 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() { | |
| 31 | + | if wv == nil { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() }) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (wv *WebView) startAdaptiveFrameRatePolling() { | |
| 38 | + | if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | wv.mu.Lock() | |
| 43 | + | if wv.adaptiveFrameRatePollID != 0 || wv.host == nil { | |
| 44 | + | wv.mu.Unlock() | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | cb := new(glib.SourceFunc) | |
| 48 | + | *cb = func(uintptr) bool { | |
| 49 | + | if wv.destroyed.Load() { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | wv.applyAdaptiveWindowlessFrameRate() | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | wv.adaptiveFrameRatePoll = cb | |
| 56 | + | wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0) | |
| 57 | + | wv.mu.Unlock() | |
| 58 | + | ||
| 59 | + | wv.applyAdaptiveWindowlessFrameRate() | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (wv *WebView) stopAdaptiveFrameRatePolling() { | |
| 63 | + | if wv == nil { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | wv.mu.Lock() | |
| 67 | + | pollID := wv.adaptiveFrameRatePollID | |
| 68 | + | wv.adaptiveFrameRatePollID = 0 | |
| 69 | + | wv.adaptiveFrameRatePoll = nil | |
| 70 | + | wv.mu.Unlock() | |
| 71 | + | if pollID != 0 { | |
| 72 | + | glib.SourceRemove(pollID) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (wv *WebView) applyAdaptiveWindowlessFrameRate() { | |
| 77 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | widget := wv.viewBridge.Widget() | |
| 81 | + | refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget) | |
| 82 | + | if !ok || !isWaylandDisplayName(displayName) { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax) | |
| 86 | + | ||
| 87 | + | wv.mu.Lock() | |
| 88 | + | host := wv.host | |
| 89 | + | if host == nil || fps == wv.lastAdaptiveFrameRate { | |
| 90 | + | wv.mu.Unlock() | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | old := wv.lastAdaptiveFrameRate | |
| 94 | + | wv.lastAdaptiveFrameRate = fps | |
| 95 | + | wv.mu.Unlock() | |
| 96 | + | ||
| 97 | + | host.SetWindowlessFrameRate(fps) | |
| 98 | + | if wv.ctx != nil { | |
| 99 | + | logging.FromContext(wv.ctx).Info(). | |
| 100 | + | Int32("old_windowless_frame_rate", old). | |
| 101 | + | Int32("windowless_frame_rate", fps). | |
| 102 | + | Int("monitor_refresh_millihz", refreshRateMilliHz). | |
| 103 | + | Str("display", displayName). | |
| 104 | + | Msg("cef: adaptive windowless frame rate updated") | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) { | |
| 109 | + | if widget == nil { | |
| 110 | + | return 0, "", false | |
| 111 | + | } | |
| 112 | + | native := widget.GetNative() | |
| 113 | + | if native == nil { | |
| 114 | + | return 0, "", false | |
| 115 | + | } | |
| 116 | + | surface := native.GetSurface() | |
| 117 | + | if surface == nil { | |
| 118 | + | return 0, "", false | |
| 119 | + | } | |
| 120 | + | display := surface.GetDisplay() | |
| 121 | + | if display == nil { | |
| 122 | + | return 0, "", false | |
| 123 | + | } | |
| 124 | + | monitor := display.GetMonitorAtSurface(surface) | |
| 125 | + | if monitor == nil { | |
| 126 | + | return 0, display.GetName(), false | |
| 127 | + | } | |
| 128 | + | return monitor.GetRefreshRate(), display.GetName(), true | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func isWaylandDisplayName(name string) bool { | |
| 132 | + | return strings.Contains(strings.ToLower(name), "wayland") | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |||
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const adaptiveFrameRatePollIntervalMS = 1000 | |||
| 14 | + | ||||
| 15 | + | func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 { | |||
| 16 | + | return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{ | |||
| 17 | + | DefaultFPS: fallbackFPS, | |||
| 18 | + | MinFPS: fallbackFPS, | |||
| 19 | + | MaxFPS: maxFPS, | |||
| 20 | + | }) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | |||
| 24 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() }) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() { | |||
| 31 | + | if wv == nil { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() }) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (wv *WebView) startAdaptiveFrameRatePolling() { | |||
| 38 | + | if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | wv.mu.Lock() | |||
| 43 | + | if wv.adaptiveFrameRatePollID != 0 || wv.host == nil { | |||
| 44 | + | wv.mu.Unlock() | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | cb := new(glib.SourceFunc) | |||
| 48 | + | *cb = func(uintptr) bool { | |||
| 49 | + | if wv.destroyed.Load() { | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | wv.applyAdaptiveWindowlessFrameRate() | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | wv.adaptiveFrameRatePoll = cb | |||
| 56 | + | wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0) | |||
| 57 | + | wv.mu.Unlock() | |||
| 58 | + | ||||
| 59 | + | wv.applyAdaptiveWindowlessFrameRate() | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (wv *WebView) stopAdaptiveFrameRatePolling() { | |||
| 63 | + | if wv == nil { | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | wv.mu.Lock() | |||
| 67 | + | pollID := wv.adaptiveFrameRatePollID | |||
| 68 | + | wv.adaptiveFrameRatePollID = 0 | |||
| 69 | + | wv.adaptiveFrameRatePoll = nil | |||
| 70 | + | wv.mu.Unlock() | |||
| 71 | + | if pollID != 0 { | |||
| 72 | + | glib.SourceRemove(pollID) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (wv *WebView) applyAdaptiveWindowlessFrameRate() { | |||
| 77 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | widget := wv.viewBridge.Widget() | |||
| 81 | + | refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget) | |||
| 82 | + | if !ok || !isWaylandDisplayName(displayName) { | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax) | |||
| 86 | + | ||||
| 87 | + | wv.mu.Lock() | |||
| 88 | + | host := wv.host | |||
| 89 | + | if host == nil || fps == wv.lastAdaptiveFrameRate { | |||
| 90 | + | wv.mu.Unlock() | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | old := wv.lastAdaptiveFrameRate | |||
| 94 | + | wv.lastAdaptiveFrameRate = fps | |||
| 95 | + | wv.mu.Unlock() | |||
| 96 | + | ||||
| 97 | + | host.SetWindowlessFrameRate(fps) | |||
| 98 | + | if wv.ctx != nil { | |||
| 99 | + | logging.FromContext(wv.ctx).Info(). | |||
| 100 | + | Int32("old_windowless_frame_rate", old). | |||
| 101 | + | Int32("windowless_frame_rate", fps). | |||
| 102 | + | Int("monitor_refresh_millihz", refreshRateMilliHz). | |||
| 103 | + | Str("display", displayName). | |||
| 104 | + | Msg("cef: adaptive windowless frame rate updated") | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) { | |||
| 109 | + | if widget == nil { | |||
| 110 | + | return 0, "", false | |||
| 111 | + | } | |||
| 112 | + | native := widget.GetNative() | |||
| 113 | + | if native == nil { | |||
| 114 | + | return 0, "", false | |||
| 115 | + | } | |||
| 116 | + | surface := native.GetSurface() | |||
| 117 | + | if surface == nil { | |||
| 118 | + | return 0, "", false | |||
| 119 | + | } | |||
| 120 | + | display := surface.GetDisplay() | |||
| 121 | + | if display == nil { | |||
| 122 | + | return 0, "", false | |||
| 123 | + | } | |||
| 124 | + | monitor := display.GetMonitorAtSurface(surface) | |||
| 125 | + | if monitor == nil { | |||
| 126 | + | return 0, display.GetName(), false | |||
| 127 | + | } | |||
| 128 | + | return monitor.GetRefreshRate(), display.GetName(), true | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func isWaylandDisplayName(name string) bool { | |||
| 132 | + | return strings.Contains(strings.ToLower(name), "wayland") | |||
| 133 | + | } | |||
A internal/infrastructure/cef/adaptive_frame_rate_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestAdaptiveFrameRateForRefresh(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | refreshMilliHz int | |
| 9 | + | fallbackFPS int32 | |
| 10 | + | maxFPS int32 | |
| 11 | + | want int32 | |
| 12 | + | }{ | |
| 13 | + | {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60}, | |
| 14 | + | {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144}, | |
| 15 | + | {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | for _, tt := range tests { | |
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |
| 20 | + | got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS) | |
| 21 | + | if got != tt.want { | |
| 22 | + | t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want) | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestIsWaylandDisplayName(t *testing.T) { | |
| 29 | + | if !isWaylandDisplayName("Wayland") || !isWaylandDisplayName("wayland-0") { | |
| 30 | + | t.Fatal("expected wayland display names to match") | |
| 31 | + | } | |
| 32 | + | if isWaylandDisplayName("x11") || isWaylandDisplayName("") { | |
| 33 | + | t.Fatal("unexpected non-wayland display match") | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestAdaptiveFrameRateForRefresh(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | refreshMilliHz int | |||
| 9 | + | fallbackFPS int32 | |||
| 10 | + | maxFPS int32 | |||
| 11 | + | want int32 | |||
| 12 | + | }{ | |||
| 13 | + | {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60}, | |||
| 14 | + | {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144}, | |||
| 15 | + | {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | for _, tt := range tests { | |||
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 20 | + | got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS) | |||
| 21 | + | if got != tt.want { | |||
| 22 | + | t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want) | |||
| 23 | + | } | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestIsWaylandDisplayName(t *testing.T) { | |||
| 29 | + | if !isWaylandDisplayName("Wayland") || !isWaylandDisplayName("wayland-0") { | |||
| 30 | + | t.Fatal("expected wayland display names to match") | |||
| 31 | + | } | |||
| 32 | + | if isWaylandDisplayName("x11") || isWaylandDisplayName("") { | |||
| 33 | + | t.Fatal("unexpected non-wayland display match") | |||
| 34 | + | } | |||
| 35 | + | } | |||
M internal/infrastructure/cef/cef2gtk_adapter.go +14 -0
| @@ -209,6 +209,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |||
| 209 | 209 | return a.view.ConfigureProfiling(opts) | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | + | // RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in | |
| 213 | + | // the bridge profiler when profiling is enabled. It is a no-op otherwise. | |
| 214 | + | func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() { | |
| 215 | + | if a == nil || a.destroyed.Load() { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | a.viewMu.RLock() | |
| 219 | + | defer a.viewMu.RUnlock() | |
| 220 | + | if a.view == nil { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | a.view.RecordExternalBeginFrameSent() | |
| 224 | + | } | |
| 225 | + | ||
| 212 | 226 | // AttachInput attaches GTK event controllers to the view and forwards input | |
| 213 | 227 | // to the given CEF browser host. Must be called on the GTK main thread. | |
| 214 | 228 | // | |
| @@ -209,6 +209,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |||||
| 209 | return a.view.ConfigureProfiling(opts) | 209 | return a.view.ConfigureProfiling(opts) | ||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | + | // RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in | |||
| 213 | + | // the bridge profiler when profiling is enabled. It is a no-op otherwise. | |||
| 214 | + | func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() { | |||
| 215 | + | if a == nil || a.destroyed.Load() { | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | a.viewMu.RLock() | |||
| 219 | + | defer a.viewMu.RUnlock() | |||
| 220 | + | if a.view == nil { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | a.view.RecordExternalBeginFrameSent() | |||
| 224 | + | } | |||
| 225 | + | ||||
| 212 | // AttachInput attaches GTK event controllers to the view and forwards input | 226 | // AttachInput attaches GTK event controllers to the view and forwards input | ||
| 213 | // to the given CEF browser host. Must be called on the GTK main thread. | 227 | // to the given CEF browser host. Must be called on the GTK main thread. | ||
| 214 | // | 228 | // | ||
M internal/infrastructure/cef/engine_init.go +8 -7
| @@ -97,8 +97,12 @@ func NewEngine( | |||
| 97 | 97 | return wireEngine( | |
| 98 | 98 | ctx, | |
| 99 | 99 | eng, | |
| 100 | - | windowlessFrameRate, | |
| 101 | - | audioFactory, | |
| 100 | + | webViewFactoryOptions{ | |
| 101 | + | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, | |
| 102 | + | windowlessFrameRate: windowlessFrameRate, | |
| 103 | + | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | |
| 104 | + | audioOutputFactory: audioFactory, | |
| 105 | + | }, | |
| 102 | 106 | logger, | |
| 103 | 107 | deps.CurrentConfigPayload, | |
| 104 | 108 | deps.DefaultConfigPayload, | |
| @@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string { | |||
| 215 | 219 | // wireEngine creates factory, pool, and scheme handler after CEF init. | |
| 216 | 220 | func wireEngine( | |
| 217 | 221 | ctx context.Context, eng *Engine, | |
| 218 | - | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 222 | + | factoryOptions webViewFactoryOptions, logger *zerolog.Logger, | |
| 219 | 223 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 220 | 224 | ) (*Engine, error) { | |
| 221 | - | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | |
| 222 | - | windowlessFrameRate: windowlessFrameRate, | |
| 223 | - | audioOutputFactory: audioFactory, | |
| 224 | - | }) | |
| 225 | + | eng.factory = newWebViewFactory(eng, factoryOptions) | |
| 225 | 226 | eng.pool = newWebViewPool(eng.factory) | |
| 226 | 227 | eng.contentInj = newContentInjector(eng, nil) | |
| 227 | 228 | ||
| @@ -97,8 +97,12 @@ func NewEngine( | |||||
| 97 | return wireEngine( | 97 | return wireEngine( | ||
| 98 | ctx, | 98 | ctx, | ||
| 99 | eng, | 99 | eng, | ||
| 100 | - | windowlessFrameRate, | 100 | + | webViewFactoryOptions{ |
| 101 | - | audioFactory, | 101 | + | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, |
| 102 | + | windowlessFrameRate: windowlessFrameRate, | |||
| 103 | + | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | |||
| 104 | + | audioOutputFactory: audioFactory, | |||
| 105 | + | }, | |||
| 102 | logger, | 106 | logger, | ||
| 103 | deps.CurrentConfigPayload, | 107 | deps.CurrentConfigPayload, | ||
| 104 | deps.DefaultConfigPayload, | 108 | deps.DefaultConfigPayload, | ||
| @@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string { | |||||
| 215 | // wireEngine creates factory, pool, and scheme handler after CEF init. | 219 | // wireEngine creates factory, pool, and scheme handler after CEF init. | ||
| 216 | func wireEngine( | 220 | func wireEngine( | ||
| 217 | ctx context.Context, eng *Engine, | 221 | ctx context.Context, eng *Engine, | ||
| 218 | - | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | 222 | + | factoryOptions webViewFactoryOptions, logger *zerolog.Logger, |
| 219 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | 223 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | ||
| 220 | ) (*Engine, error) { | 224 | ) (*Engine, error) { | ||
| 221 | - | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | 225 | + | eng.factory = newWebViewFactory(eng, factoryOptions) |
| 222 | - | windowlessFrameRate: windowlessFrameRate, | |||
| 223 | - | audioOutputFactory: audioFactory, | |||
| 224 | - | }) | |||
| 225 | eng.pool = newWebViewPool(eng.factory) | 226 | eng.pool = newWebViewPool(eng.factory) | ||
| 226 | eng.contentInj = newContentInjector(eng, nil) | 227 | eng.contentInj = newContentInjector(eng, nil) | ||
| 227 | 228 | ||||
M internal/infrastructure/cef/factory.go +29 -18
| @@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||
| 20 | 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | |
| 21 | 21 | // CEF browser (via BrowserHostCreateBrowser). | |
| 22 | 22 | type WebViewFactory struct { | |
| 23 | - | engine *Engine | |
| 24 | - | nextID atomic.Uint64 | |
| 25 | - | windowlessFrameRate int32 | |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | - | audioOutputFactory port.AudioOutputFactory | |
| 23 | + | engine *Engine | |
| 24 | + | nextID atomic.Uint64 | |
| 25 | + | adaptiveWindowlessFrameRate bool | |
| 26 | + | windowlessFrameRate int32 | |
| 27 | + | windowlessFrameRateMax int32 | |
| 28 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 29 | + | audioOutputFactory port.AudioOutputFactory | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | type webViewFactoryOptions struct { | |
| 31 | - | windowlessFrameRate int32 | |
| 32 | - | audioOutputFactory port.AudioOutputFactory | |
| 33 | + | adaptiveWindowlessFrameRate bool | |
| 34 | + | windowlessFrameRate int32 | |
| 35 | + | windowlessFrameRateMax int32 | |
| 36 | + | audioOutputFactory port.AudioOutputFactory | |
| 33 | 37 | } | |
| 34 | 38 | ||
| 35 | 39 | type resizeNotifiableBrowserHost interface { | |
| @@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto | |||
| 49 | 53 | if opts.windowlessFrameRate < 1 { | |
| 50 | 54 | opts.windowlessFrameRate = 60 | |
| 51 | 55 | } | |
| 56 | + | if opts.windowlessFrameRateMax < 1 { | |
| 57 | + | opts.windowlessFrameRateMax = 240 | |
| 58 | + | } | |
| 52 | 59 | return &WebViewFactory{ | |
| 53 | - | engine: engine, | |
| 54 | - | windowlessFrameRate: opts.windowlessFrameRate, | |
| 55 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 60 | + | engine: engine, | |
| 61 | + | adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate, | |
| 62 | + | windowlessFrameRate: opts.windowlessFrameRate, | |
| 63 | + | windowlessFrameRateMax: opts.windowlessFrameRateMax, | |
| 64 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 56 | 65 | } | |
| 57 | 66 | } | |
| 58 | 67 | ||
| @@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 103 | 112 | } | |
| 104 | 113 | ||
| 105 | 114 | wv := &WebView{ | |
| 106 | - | id: id, | |
| 107 | - | ctx: ctx, | |
| 108 | - | engine: f.engine, | |
| 109 | - | factory: f, | |
| 110 | - | viewBridge: viewBridge, | |
| 111 | - | audioOutputFactory: f.audioOutputFactory, | |
| 112 | - | windowlessFrameRate: f.windowlessFrameRate, | |
| 113 | - | backgroundColor: f.bgColor.Load(), | |
| 115 | + | id: id, | |
| 116 | + | ctx: ctx, | |
| 117 | + | engine: f.engine, | |
| 118 | + | factory: f, | |
| 119 | + | viewBridge: viewBridge, | |
| 120 | + | audioOutputFactory: f.audioOutputFactory, | |
| 121 | + | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, | |
| 122 | + | windowlessFrameRate: f.windowlessFrameRate, | |
| 123 | + | windowlessFrameRateMax: f.windowlessFrameRateMax, | |
| 124 | + | backgroundColor: f.bgColor.Load(), | |
| 114 | 125 | } | |
| 115 | 126 | ||
| 116 | 127 | handlers := &handlerSet{wv: wv} | |
| @@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||||
| 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | ||
| 21 | // CEF browser (via BrowserHostCreateBrowser). | 21 | // CEF browser (via BrowserHostCreateBrowser). | ||
| 22 | type WebViewFactory struct { | 22 | type WebViewFactory struct { | ||
| 23 | - | engine *Engine | 23 | + | engine *Engine |
| 24 | - | nextID atomic.Uint64 | 24 | + | nextID atomic.Uint64 |
| 25 | - | windowlessFrameRate int32 | 25 | + | adaptiveWindowlessFrameRate bool |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | + | windowlessFrameRate int32 |
| 27 | - | audioOutputFactory port.AudioOutputFactory | 27 | + | windowlessFrameRateMax int32 |
| 28 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |||
| 29 | + | audioOutputFactory port.AudioOutputFactory | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | type webViewFactoryOptions struct { | 32 | type webViewFactoryOptions struct { | ||
| 31 | - | windowlessFrameRate int32 | 33 | + | adaptiveWindowlessFrameRate bool |
| 32 | - | audioOutputFactory port.AudioOutputFactory | 34 | + | windowlessFrameRate int32 |
| 35 | + | windowlessFrameRateMax int32 | |||
| 36 | + | audioOutputFactory port.AudioOutputFactory | |||
| 33 | } | 37 | } | ||
| 34 | 38 | ||||
| 35 | type resizeNotifiableBrowserHost interface { | 39 | type resizeNotifiableBrowserHost interface { | ||
| @@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto | |||||
| 49 | if opts.windowlessFrameRate < 1 { | 53 | if opts.windowlessFrameRate < 1 { | ||
| 50 | opts.windowlessFrameRate = 60 | 54 | opts.windowlessFrameRate = 60 | ||
| 51 | } | 55 | } | ||
| 56 | + | if opts.windowlessFrameRateMax < 1 { | |||
| 57 | + | opts.windowlessFrameRateMax = 240 | |||
| 58 | + | } | |||
| 52 | return &WebViewFactory{ | 59 | return &WebViewFactory{ | ||
| 53 | - | engine: engine, | 60 | + | engine: engine, |
| 54 | - | windowlessFrameRate: opts.windowlessFrameRate, | 61 | + | adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate, |
| 55 | - | audioOutputFactory: opts.audioOutputFactory, | 62 | + | windowlessFrameRate: opts.windowlessFrameRate, |
| 63 | + | windowlessFrameRateMax: opts.windowlessFrameRateMax, | |||
| 64 | + | audioOutputFactory: opts.audioOutputFactory, | |||
| 56 | } | 65 | } | ||
| 57 | } | 66 | } | ||
| 58 | 67 | ||||
| @@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 103 | } | 112 | } | ||
| 104 | 113 | ||||
| 105 | wv := &WebView{ | 114 | wv := &WebView{ | ||
| 106 | - | id: id, | 115 | + | id: id, |
| 107 | - | ctx: ctx, | 116 | + | ctx: ctx, |
| 108 | - | engine: f.engine, | 117 | + | engine: f.engine, |
| 109 | - | factory: f, | 118 | + | factory: f, |
| 110 | - | viewBridge: viewBridge, | 119 | + | viewBridge: viewBridge, |
| 111 | - | audioOutputFactory: f.audioOutputFactory, | 120 | + | audioOutputFactory: f.audioOutputFactory, |
| 112 | - | windowlessFrameRate: f.windowlessFrameRate, | 121 | + | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, |
| 113 | - | backgroundColor: f.bgColor.Load(), | 122 | + | windowlessFrameRate: f.windowlessFrameRate, |
| 123 | + | windowlessFrameRateMax: f.windowlessFrameRateMax, | |||
| 124 | + | backgroundColor: f.bgColor.Load(), | |||
| 114 | } | 125 | } | ||
| 115 | 126 | ||||
| 116 | handlers := &handlerSet{wv: wv} | 127 | handlers := &handlerSet{wv: wv} | ||
M internal/infrastructure/cef/runtime_config.go +9 -7
| @@ -15,13 +15,15 @@ var ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | type RuntimeConfig struct { | |
| 18 | - | CEFDir string | |
| 19 | - | LogFile string | |
| 20 | - | LogSeverity int32 | |
| 21 | - | RenderStack string | |
| 22 | - | WindowlessFrameRate int32 | |
| 23 | - | EnableAudioHandler bool | |
| 24 | - | TraceHandlers bool | |
| 18 | + | CEFDir string | |
| 19 | + | LogFile string | |
| 20 | + | LogSeverity int32 | |
| 21 | + | RenderStack string | |
| 22 | + | AdaptiveWindowlessFrameRate bool | |
| 23 | + | WindowlessFrameRate int32 | |
| 24 | + | WindowlessFrameRateMax int32 | |
| 25 | + | EnableAudioHandler bool | |
| 26 | + | TraceHandlers bool | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | |
| @@ -15,13 +15,15 @@ var ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | type RuntimeConfig struct { | 17 | type RuntimeConfig struct { | ||
| 18 | - | CEFDir string | 18 | + | CEFDir string |
| 19 | - | LogFile string | 19 | + | LogFile string |
| 20 | - | LogSeverity int32 | 20 | + | LogSeverity int32 |
| 21 | - | RenderStack string | 21 | + | RenderStack string |
| 22 | - | WindowlessFrameRate int32 | 22 | + | AdaptiveWindowlessFrameRate bool |
| 23 | - | EnableAudioHandler bool | 23 | + | WindowlessFrameRate int32 |
| 24 | - | TraceHandlers bool | 24 | + | WindowlessFrameRateMax int32 |
| 25 | + | EnableAudioHandler bool | |||
| 26 | + | TraceHandlers bool | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | 29 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | ||
M internal/infrastructure/cef/webview.go +13 -0
| @@ -103,6 +103,12 @@ type WebView struct { | |||
| 103 | 103 | viewportRealizeSignalID uint | |
| 104 | 104 | viewportResizePulseSeq atomic.Uint64 | |
| 105 | 105 | ||
| 106 | + | adaptiveWindowlessFrameRate bool | |
| 107 | + | windowlessFrameRateMax int32 | |
| 108 | + | adaptiveFrameRatePoll *glib.SourceFunc | |
| 109 | + | adaptiveFrameRatePollID uint | |
| 110 | + | lastAdaptiveFrameRate int32 | |
| 111 | + | ||
| 106 | 112 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 107 | 113 | // widget is visible. Access is guarded by mu. | |
| 108 | 114 | beginFrameTick *gtk.TickCallback | |
| @@ -1093,6 +1099,7 @@ func (wv *WebView) Destroy() { | |||
| 1093 | 1099 | wv.stopRenderStallWatchdog() | |
| 1094 | 1100 | wv.cancelSelectionDebounce() | |
| 1095 | 1101 | wv.closeAudioStream() | |
| 1102 | + | wv.scheduleStopAdaptiveFrameRatePolling() | |
| 1096 | 1103 | wv.scheduleStopBeginFrameLoop() | |
| 1097 | 1104 | wv.mu.RLock() | |
| 1098 | 1105 | host := wv.host | |
| @@ -1736,6 +1743,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1736 | 1743 | return false | |
| 1737 | 1744 | } | |
| 1738 | 1745 | host.SendExternalBeginFrame() | |
| 1746 | + | if wv.viewBridge != nil { | |
| 1747 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |
| 1748 | + | } | |
| 1739 | 1749 | return true | |
| 1740 | 1750 | } | |
| 1741 | 1751 | wv.beginFrameTick = cb | |
| @@ -1745,6 +1755,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1745 | 1755 | ||
| 1746 | 1756 | if host != nil { | |
| 1747 | 1757 | host.SendExternalBeginFrame() | |
| 1758 | + | if wv.viewBridge != nil { | |
| 1759 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |
| 1760 | + | } | |
| 1748 | 1761 | } | |
| 1749 | 1762 | } | |
| 1750 | 1763 | ||
| @@ -103,6 +103,12 @@ type WebView struct { | |||||
| 103 | viewportRealizeSignalID uint | 103 | viewportRealizeSignalID uint | ||
| 104 | viewportResizePulseSeq atomic.Uint64 | 104 | viewportResizePulseSeq atomic.Uint64 | ||
| 105 | 105 | ||||
| 106 | + | adaptiveWindowlessFrameRate bool | |||
| 107 | + | windowlessFrameRateMax int32 | |||
| 108 | + | adaptiveFrameRatePoll *glib.SourceFunc | |||
| 109 | + | adaptiveFrameRatePollID uint | |||
| 110 | + | lastAdaptiveFrameRate int32 | |||
| 111 | + | ||||
| 106 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 112 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | ||
| 107 | // widget is visible. Access is guarded by mu. | 113 | // widget is visible. Access is guarded by mu. | ||
| 108 | beginFrameTick *gtk.TickCallback | 114 | beginFrameTick *gtk.TickCallback | ||
| @@ -1093,6 +1099,7 @@ func (wv *WebView) Destroy() { | |||||
| 1093 | wv.stopRenderStallWatchdog() | 1099 | wv.stopRenderStallWatchdog() | ||
| 1094 | wv.cancelSelectionDebounce() | 1100 | wv.cancelSelectionDebounce() | ||
| 1095 | wv.closeAudioStream() | 1101 | wv.closeAudioStream() | ||
| 1102 | + | wv.scheduleStopAdaptiveFrameRatePolling() | |||
| 1096 | wv.scheduleStopBeginFrameLoop() | 1103 | wv.scheduleStopBeginFrameLoop() | ||
| 1097 | wv.mu.RLock() | 1104 | wv.mu.RLock() | ||
| 1098 | host := wv.host | 1105 | host := wv.host | ||
| @@ -1736,6 +1743,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 1736 | return false | 1743 | return false | ||
| 1737 | } | 1744 | } | ||
| 1738 | host.SendExternalBeginFrame() | 1745 | host.SendExternalBeginFrame() | ||
| 1746 | + | if wv.viewBridge != nil { | |||
| 1747 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |||
| 1748 | + | } | |||
| 1739 | return true | 1749 | return true | ||
| 1740 | } | 1750 | } | ||
| 1741 | wv.beginFrameTick = cb | 1751 | wv.beginFrameTick = cb | ||
| @@ -1745,6 +1755,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 1745 | 1755 | ||||
| 1746 | if host != nil { | 1756 | if host != nil { | ||
| 1747 | host.SendExternalBeginFrame() | 1757 | host.SendExternalBeginFrame() | ||
| 1758 | + | if wv.viewBridge != nil { | |||
| 1759 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |||
| 1760 | + | } | |||
| 1748 | } | 1761 | } | ||
| 1749 | } | 1762 | } | ||
| 1750 | 1763 | ||||
M internal/infrastructure/cef/webview_handlers.go +7 -0
| @@ -667,6 +667,11 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 667 | 667 | // painting/caret updates until explicitly told the browser is shown. | |
| 668 | 668 | host.WasHidden(0) | |
| 669 | 669 | if h.wv != nil { | |
| 670 | + | if h.wv.ctx != nil { | |
| 671 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 672 | + | Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()). | |
| 673 | + | Msg("cef: browser runtime windowless frame rate") | |
| 674 | + | } | |
| 670 | 675 | h.wv.SyncViewport(h.wv.ctx, "after-created") | |
| 671 | 676 | } | |
| 672 | 677 | return state | |
| @@ -709,6 +714,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 709 | 714 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | |
| 710 | 715 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | |
| 711 | 716 | } | |
| 717 | + | h.wv.scheduleStartAdaptiveFrameRatePolling() | |
| 712 | 718 | h.wv.scheduleStartBeginFrameLoop() | |
| 713 | 719 | h.wv.fireReadyToShow() | |
| 714 | 720 | } | |
| @@ -757,6 +763,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 757 | 763 | } | |
| 758 | 764 | }) | |
| 759 | 765 | } | |
| 766 | + | h.wv.scheduleStopAdaptiveFrameRatePolling() | |
| 760 | 767 | h.wv.scheduleStopBeginFrameLoop() | |
| 761 | 768 | ||
| 762 | 769 | h.wv.mu.RLock() | |
| @@ -667,6 +667,11 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 667 | // painting/caret updates until explicitly told the browser is shown. | 667 | // painting/caret updates until explicitly told the browser is shown. | ||
| 668 | host.WasHidden(0) | 668 | host.WasHidden(0) | ||
| 669 | if h.wv != nil { | 669 | if h.wv != nil { | ||
| 670 | + | if h.wv.ctx != nil { | |||
| 671 | + | logging.FromContext(h.wv.ctx).Info(). | |||
| 672 | + | Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()). | |||
| 673 | + | Msg("cef: browser runtime windowless frame rate") | |||
| 674 | + | } | |||
| 670 | h.wv.SyncViewport(h.wv.ctx, "after-created") | 675 | h.wv.SyncViewport(h.wv.ctx, "after-created") | ||
| 671 | } | 676 | } | ||
| 672 | return state | 677 | return state | ||
| @@ -709,6 +714,7 @@ func (h *handlerSet) finishAfterCreated( | |||||
| 709 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | 714 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | ||
| 710 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | 715 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | ||
| 711 | } | 716 | } | ||
| 717 | + | h.wv.scheduleStartAdaptiveFrameRatePolling() | |||
| 712 | h.wv.scheduleStartBeginFrameLoop() | 718 | h.wv.scheduleStartBeginFrameLoop() | ||
| 713 | h.wv.fireReadyToShow() | 719 | h.wv.fireReadyToShow() | ||
| 714 | } | 720 | } | ||
| @@ -757,6 +763,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||||
| 757 | } | 763 | } | ||
| 758 | }) | 764 | }) | ||
| 759 | } | 765 | } | ||
| 766 | + | h.wv.scheduleStopAdaptiveFrameRatePolling() | |||
| 760 | h.wv.scheduleStopBeginFrameLoop() | 767 | h.wv.scheduleStopBeginFrameLoop() | ||
| 761 | 768 | ||||
| 762 | h.wv.mu.RLock() | 769 | h.wv.mu.RLock() | ||
A internal/infrastructure/config/cef_frame_rate_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) { | |
| 6 | + | cfg := DefaultConfig().Engine.CEF | |
| 7 | + | ||
| 8 | + | if !cfg.AdaptiveWindowlessFrameRate { | |
| 9 | + | t.Fatal("adaptive windowless frame-rate should be enabled by default") | |
| 10 | + | } | |
| 11 | + | if cfg.WindowlessFrameRate != 0 { | |
| 12 | + | t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate) | |
| 13 | + | } | |
| 14 | + | if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) { | |
| 15 | + | t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate) | |
| 16 | + | } | |
| 17 | + | if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) { | |
| 18 | + | t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax) | |
| 19 | + | } | |
| 20 | + | if !cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 21 | + | t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set") | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) { | |
| 26 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144} | |
| 27 | + | ||
| 28 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 29 | + | t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate") | |
| 30 | + | } | |
| 31 | + | if got := cfg.CEFWindowlessFrameRate(); got != 144 { | |
| 32 | + | t.Fatalf("explicit frame-rate = %d, want 144", got) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) { | |||
| 6 | + | cfg := DefaultConfig().Engine.CEF | |||
| 7 | + | ||||
| 8 | + | if !cfg.AdaptiveWindowlessFrameRate { | |||
| 9 | + | t.Fatal("adaptive windowless frame-rate should be enabled by default") | |||
| 10 | + | } | |||
| 11 | + | if cfg.WindowlessFrameRate != 0 { | |||
| 12 | + | t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate) | |||
| 13 | + | } | |||
| 14 | + | if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) { | |||
| 15 | + | t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate) | |||
| 16 | + | } | |||
| 17 | + | if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) { | |||
| 18 | + | t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax) | |||
| 19 | + | } | |||
| 20 | + | if !cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 21 | + | t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set") | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) { | |||
| 26 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144} | |||
| 27 | + | ||||
| 28 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 29 | + | t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate") | |||
| 30 | + | } | |||
| 31 | + | if got := cfg.CEFWindowlessFrameRate(); got != 144 { | |||
| 32 | + | t.Fatalf("explicit frame-rate = %d, want 144", got) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/infrastructure/config/defaults.go +10 -7
| @@ -67,9 +67,10 @@ const ( | |||
| 67 | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| 68 | 68 | ||
| 69 | 69 | // Performance defaults | |
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | |
| 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | + | defaultCEFWindowlessFrameRate = 60 // fallback OSR frame rate for CEF | |
| 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |
| 73 | 74 | ||
| 74 | 75 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 75 | 76 | defaultSkiaCPUPaintingThreads = 0 | |
| @@ -171,10 +172,12 @@ func DefaultConfig() *Config { | |||
| 171 | 172 | // matches Epiphany's model and avoids a misleading setting. | |
| 172 | 173 | CookiePolicy: CookiePolicyAlways, | |
| 173 | 174 | CEF: CEFEngineConfig{ | |
| 174 | - | RenderStack: CEFRenderStackVulkan, | |
| 175 | - | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 176 | - | LogFile: "", | |
| 177 | - | EnableAudioHandler: true, | |
| 175 | + | RenderStack: CEFRenderStackVulkan, | |
| 176 | + | AdaptiveWindowlessFrameRate: true, | |
| 177 | + | WindowlessFrameRate: 0, | |
| 178 | + | WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax, | |
| 179 | + | LogFile: "", | |
| 180 | + | EnableAudioHandler: true, | |
| 178 | 181 | }, | |
| 179 | 182 | WebKit: WebKitEngineConfig{ | |
| 180 | 183 | ITPEnabled: true, | |
| @@ -67,9 +67,10 @@ const ( | |||||
| 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | ||
| 68 | 68 | ||||
| 69 | // Performance defaults | 69 | // Performance defaults | ||
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | 72 | + | defaultCEFWindowlessFrameRate = 60 // fallback OSR frame rate for CEF |
| 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |||
| 73 | 74 | ||||
| 74 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | 75 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | ||
| 75 | defaultSkiaCPUPaintingThreads = 0 | 76 | defaultSkiaCPUPaintingThreads = 0 | ||
| @@ -171,10 +172,12 @@ func DefaultConfig() *Config { | |||||
| 171 | // matches Epiphany's model and avoids a misleading setting. | 172 | // matches Epiphany's model and avoids a misleading setting. | ||
| 172 | CookiePolicy: CookiePolicyAlways, | 173 | CookiePolicy: CookiePolicyAlways, | ||
| 173 | CEF: CEFEngineConfig{ | 174 | CEF: CEFEngineConfig{ | ||
| 174 | - | RenderStack: CEFRenderStackVulkan, | 175 | + | RenderStack: CEFRenderStackVulkan, |
| 175 | - | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | 176 | + | AdaptiveWindowlessFrameRate: true, |
| 176 | - | LogFile: "", | 177 | + | WindowlessFrameRate: 0, |
| 177 | - | EnableAudioHandler: true, | 178 | + | WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax, |
| 179 | + | LogFile: "", | |||
| 180 | + | EnableAudioHandler: true, | |||
| 178 | }, | 181 | }, | ||
| 179 | WebKit: WebKitEngineConfig{ | 182 | WebKit: WebKitEngineConfig{ | ||
| 180 | ITPEnabled: true, | 183 | ITPEnabled: true, | ||
M internal/infrastructure/config/defaults_test.go +2 -0
| @@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 19 | 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | |
| 20 | 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | |
| 21 | 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | |
| 22 | + | assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate()) | |
| 22 | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | |
| 24 | + | assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax()) | |
| 23 | 25 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 24 | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 25 | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| @@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | ||
| 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | ||
| 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | ||
| 22 | + | assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate()) | |||
| 22 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | ||
| 24 | + | assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax()) | |||
| 23 | assert.Empty(t, cfg.Engine.CEF.LogFile) | 25 | assert.Empty(t, cfg.Engine.CEF.LogFile) | ||
| 24 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | ||
| 25 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
M internal/infrastructure/config/engine.go +19 -1
| @@ -100,9 +100,16 @@ type CEFEngineConfig struct { | |||
| 100 | 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | |
| 101 | 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | |
| 102 | 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | |
| 103 | + | // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate | |
| 104 | + | // and updates CEF's OSR frame-rate while the browser moves across monitors. | |
| 105 | + | // It is ignored when WindowlessFrameRate is explicitly set. | |
| 106 | + | AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` | |
| 103 | 107 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | |
| 104 | - | // Default: 60. Higher values increase CPU usage. | |
| 108 | + | // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the | |
| 109 | + | // active Wayland monitor refresh rate. | |
| 105 | 110 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | |
| 111 | + | // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates. | |
| 112 | + | WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` | |
| 106 | 113 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 107 | 114 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 108 | 115 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | |
| @@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 { | |||
| 127 | 134 | return defaultCEFWindowlessFrameRate | |
| 128 | 135 | } | |
| 129 | 136 | ||
| 137 | + | func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 { | |
| 138 | + | if c.WindowlessFrameRateMax > 0 { | |
| 139 | + | return c.WindowlessFrameRateMax | |
| 140 | + | } | |
| 141 | + | return defaultCEFWindowlessFrameRateMax | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool { | |
| 145 | + | return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0 | |
| 146 | + | } | |
| 147 | + | ||
| 130 | 148 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | |
| 131 | 149 | // new [engine] config section, for use with ResolvePerformanceProfile. | |
| 132 | 150 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | |
| @@ -100,9 +100,16 @@ type CEFEngineConfig struct { | |||||
| 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | ||
| 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | ||
| 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | ||
| 103 | + | // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate | |||
| 104 | + | // and updates CEF's OSR frame-rate while the browser moves across monitors. | |||
| 105 | + | // It is ignored when WindowlessFrameRate is explicitly set. | |||
| 106 | + | AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` | |||
| 103 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | 107 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | ||
| 104 | - | // Default: 60. Higher values increase CPU usage. | 108 | + | // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the |
| 109 | + | // active Wayland monitor refresh rate. | |||
| 105 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | 110 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | ||
| 111 | + | // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates. | |||
| 112 | + | WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` | |||
| 106 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | 113 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | ||
| 107 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | 114 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | ||
| 108 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | 115 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | ||
| @@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 { | |||||
| 127 | return defaultCEFWindowlessFrameRate | 134 | return defaultCEFWindowlessFrameRate | ||
| 128 | } | 135 | } | ||
| 129 | 136 | ||||
| 137 | + | func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 { | |||
| 138 | + | if c.WindowlessFrameRateMax > 0 { | |||
| 139 | + | return c.WindowlessFrameRateMax | |||
| 140 | + | } | |||
| 141 | + | return defaultCEFWindowlessFrameRateMax | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool { | |||
| 145 | + | return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0 | |||
| 146 | + | } | |||
| 147 | + | ||||
| 130 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | 148 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | ||
| 131 | // new [engine] config section, for use with ResolvePerformanceProfile. | 149 | // new [engine] config section, for use with ResolvePerformanceProfile. | ||
| 132 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | 150 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | ||
M internal/infrastructure/config/loader.go +3 -1
| @@ -694,7 +694,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 694 | 694 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | |
| 695 | 695 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | |
| 696 | 696 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | |
| 697 | - | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | |
| 697 | + | m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate) | |
| 698 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate) | |
| 699 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax()) | |
| 698 | 700 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 699 | 701 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | |
| 700 | 702 | ||
| @@ -694,7 +694,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 694 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | 694 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | ||
| 695 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | 695 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | ||
| 696 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | 696 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | ||
| 697 | - | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | 697 | + | m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate) |
| 698 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate) | |||
| 699 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax()) | |||
| 698 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | 700 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | ||
| 699 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | 701 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | ||
| 700 | 702 | ||||
M internal/infrastructure/config/schema_provider.go +17 -2
| @@ -939,14 +939,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 939 | 939 | Values: []string{"vulkan", "egl"}, | |
| 940 | 940 | Section: SectionPerformance, | |
| 941 | 941 | }, | |
| 942 | + | { | |
| 943 | + | Key: "engine.cef.adaptive_windowless_frame_rate", | |
| 944 | + | Type: "bool", | |
| 945 | + | Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate), | |
| 946 | + | Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set", | |
| 947 | + | Section: SectionPerformance, | |
| 948 | + | }, | |
| 942 | 949 | { | |
| 943 | 950 | Key: "engine.cef.windowless_frame_rate", | |
| 944 | 951 | Type: "int32", | |
| 945 | - | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | |
| 946 | - | Description: "Maximum off-screen frame rate for the CEF engine", | |
| 952 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate), | |
| 953 | + | Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured", | |
| 947 | 954 | Range: ">=0", | |
| 948 | 955 | Section: SectionPerformance, | |
| 949 | 956 | }, | |
| 957 | + | { | |
| 958 | + | Key: "engine.cef.windowless_frame_rate_max", | |
| 959 | + | Type: "int32", | |
| 960 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()), | |
| 961 | + | Description: "Hard cap for adaptive CEF OSR frame rate", | |
| 962 | + | Range: ">0", | |
| 963 | + | Section: SectionPerformance, | |
| 964 | + | }, | |
| 950 | 965 | { | |
| 951 | 966 | Key: "engine.webkit.skia_cpu_painting_threads", | |
| 952 | 967 | Type: "int", | |
| @@ -939,14 +939,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 939 | Values: []string{"vulkan", "egl"}, | 939 | Values: []string{"vulkan", "egl"}, | ||
| 940 | Section: SectionPerformance, | 940 | Section: SectionPerformance, | ||
| 941 | }, | 941 | }, | ||
| 942 | + | { | |||
| 943 | + | Key: "engine.cef.adaptive_windowless_frame_rate", | |||
| 944 | + | Type: "bool", | |||
| 945 | + | Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate), | |||
| 946 | + | Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set", | |||
| 947 | + | Section: SectionPerformance, | |||
| 948 | + | }, | |||
| 942 | { | 949 | { | ||
| 943 | Key: "engine.cef.windowless_frame_rate", | 950 | Key: "engine.cef.windowless_frame_rate", | ||
| 944 | Type: "int32", | 951 | Type: "int32", | ||
| 945 | - | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | 952 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate), |
| 946 | - | Description: "Maximum off-screen frame rate for the CEF engine", | 953 | + | Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured", |
| 947 | Range: ">=0", | 954 | Range: ">=0", | ||
| 948 | Section: SectionPerformance, | 955 | Section: SectionPerformance, | ||
| 949 | }, | 956 | }, | ||
| 957 | + | { | |||
| 958 | + | Key: "engine.cef.windowless_frame_rate_max", | |||
| 959 | + | Type: "int32", | |||
| 960 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()), | |||
| 961 | + | Description: "Hard cap for adaptive CEF OSR frame rate", | |||
| 962 | + | Range: ">0", | |||
| 963 | + | Section: SectionPerformance, | |||
| 964 | + | }, | |||
| 950 | { | 965 | { | ||
| 951 | Key: "engine.webkit.skia_cpu_painting_threads", | 966 | Key: "engine.webkit.skia_cpu_painting_threads", | ||
| 952 | Type: "int", | 967 | Type: "int", | ||
M internal/infrastructure/config/validation.go +3 -0
| @@ -358,6 +358,9 @@ func validateCEF(config *Config) []string { | |||
| 358 | 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | |
| 359 | 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | |
| 360 | 360 | } | |
| 361 | + | if config.Engine.CEF.WindowlessFrameRateMax < 0 { | |
| 362 | + | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0") | |
| 363 | + | } | |
| 361 | 364 | ||
| 362 | 365 | return validationErrors | |
| 363 | 366 | } | |
| @@ -358,6 +358,9 @@ func validateCEF(config *Config) []string { | |||||
| 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | ||
| 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | ||
| 360 | } | 360 | } | ||
| 361 | + | if config.Engine.CEF.WindowlessFrameRateMax < 0 { | |||
| 362 | + | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0") | |||
| 363 | + | } | |||
| 361 | 364 | ||||
| 362 | return validationErrors | 365 | return validationErrors | ||
| 363 | } | 366 | } | ||