NVENC support

This commit is contained in:
KingKDot
2026-06-06 16:08:28 -05:00
parent 7f8e5ff333
commit de67516285
29 changed files with 8820 additions and 14 deletions
@@ -108,6 +108,20 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
time.Sleep(50 * time.Millisecond)
}
if frame, captureDur, encodeDur, used, err := tryBuildDirectH264Frame(display); used {
if err != nil {
consecutiveCaptureFails.Add(1)
log.Printf("capture: direct h264 capture failed: %v (sending black frame, consecutive=%d)", err, consecutiveCaptureFails.Load())
return sendBlackFrame(ctx, env)
}
if len(frame.Data) == 0 {
consecutiveCaptureFails.Store(0)
return nil
}
consecutiveCaptureFails.Store(0)
return sendCompletedFrame(ctx, env, frame, display, t0, captureDur, encodeDur)
}
img, err := safeCaptureDisplay(display)
if err != nil {
img, err = safeCaptureDisplay(display)
@@ -222,6 +236,74 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
return err
}
func sendCompletedFrame(ctx context.Context, env *rt.Env, frame wire.Frame, display int, t0 time.Time, captureDur, encodeDur time.Duration) error {
now := time.Now()
fps := frameFPS(now)
if fps <= 0 {
fps = 1
}
frame.Header.FPS = fps
if ctx.Err() != nil {
return nil
}
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindDesktop) {
dur := time.Second / time.Duration(fps)
if dur <= 0 {
dur = 33 * time.Millisecond
}
if werr := webrtcpub.WriteH264(webrtcpub.KindDesktop, frame.Data, dur); werr != nil {
log.Printf("webrtc: write h264 failed: %v", werr)
}
statFrames.Add(1)
statCapNs.Add(captureDur.Nanoseconds())
statEncNs.Add(encodeDur.Nanoseconds())
statTotalNs.Add(time.Since(t0).Nanoseconds())
statBytes.Add(int64(len(frame.Data)))
return nil
}
if !AcquireFrameSlot() {
return nil
}
sendStart := time.Now()
err := wire.WriteMsg(ctx, env.Conn, frame)
sendDur := time.Since(sendStart)
if shouldLogFrame(now) {
total := time.Since(t0)
frames := statFrames.Load()
capAvg := avgMs(statCapNs.Load(), frames)
encAvg := avgMs(statEncNs.Load(), frames)
sendAvg := avgMs(statSendNs.Load(), frames)
totalAvg := avgMs(statTotalNs.Load(), frames)
bytesAvg := avgBytes(statBytes.Load(), frames)
detectAvg := avgMs(statDetectNs.Load(), frames)
mergeAvg := avgMs(statMergeNs.Load(), frames)
blkJpegAvg := avgMs(statBlkJpegNs.Load(), frames)
prevCopyAvg := avgMs(statPrevCopyNs.Load(), frames)
full := statFullFrames.Load()
blocks := statBlockFrames.Load()
keep := statKeepaliveFrames.Load()
regions := statBlockRegions.Load()
fallbacks := statBlockFallbacks.Load()
avgRegions := float64(0)
if blocks > 0 {
avgRegions = float64(regions) / float64(blocks)
}
log.Printf("capture: stream display=%d fps≈%d format=%s size=%d cap=%s enc=%s send=%s total=%s | avg cap=%.2fms enc=%.2fms send=%.2fms total=%.2fms avgSize=%.0fB frames=%d detect=%.2fms merge=%.2fms blkJpeg=%.2fms prevCopy=%.2fms full=%d blocks=%d keep=%d fallbacks=%d avgRegions=%.2f", display, fps, frame.Header.Format, len(frame.Data), captureDur, encodeDur, sendDur, total, capAvg, encAvg, sendAvg, totalAvg, bytesAvg, frames, detectAvg, mergeAvg, blkJpegAvg, prevCopyAvg, full, blocks, keep, fallbacks, avgRegions)
resetStats()
}
if err != nil {
ReleaseFrameSlot()
return err
}
statFrames.Add(1)
statCapNs.Add(captureDur.Nanoseconds())
statEncNs.Add(encodeDur.Nanoseconds())
statSendNs.Add(sendDur.Nanoseconds())
statTotalNs.Add(time.Since(t0).Nanoseconds())
statBytes.Add(int64(len(frame.Data)))
return nil
}
// captureAllDisplaysAndSend stitches all monitors into a single image and sends
// it as the initial frame. Used on Windows so the dashboard thumbnail shows
// every monitor, not just the primary one.
@@ -391,6 +473,8 @@ var (
fpsCount atomic.Int64
fpsLatest atomic.Int64
lastFrameLog atomic.Int64
metricsOnce sync.Once
metricsEnabled bool
lastKeyframe atomic.Int64
fullNextFrames atomic.Int64
@@ -474,6 +558,9 @@ func frameFPS(now time.Time) int {
}
func shouldLogFrame(now time.Time) bool {
if !captureMetricsEnabled() {
return false
}
last := time.Unix(0, lastFrameLog.Load())
if now.Sub(last) >= frameLogInterval {
lastFrameLog.Store(now.UnixNano())
@@ -482,6 +569,23 @@ func shouldLogFrame(now time.Time) bool {
return false
}
func captureMetricsEnabled() bool {
metricsOnce.Do(func() {
for _, name := range []string{"OVERLORD_CAPTURE_METRICS", "OVERLORD_DEV_METRICS"} {
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
case "1", "true", "yes", "on":
metricsEnabled = true
return
}
}
switch strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_MODE"))) {
case "dev", "development":
metricsEnabled = true
}
})
return metricsEnabled
}
func requestFullFrames(count int) {
if count <= 0 {
return
@@ -0,0 +1,165 @@
package capture
import (
"image"
"time"
)
type CaptureSmokeOptions struct {
Display int `json:"display"`
Frames int `json:"frames"`
FPS int `json:"fps,omitempty"`
MaxHeight int `json:"max_height,omitempty"`
}
type CaptureSmokeResult struct {
Backend string `json:"backend"`
Format string `json:"format,omitempty"`
Display int `json:"display"`
Frames int `json:"frames"`
Attempts int `json:"attempts,omitempty"`
OK int `json:"ok"`
Width int `json:"width"`
Height int `json:"height"`
AvgMS float64 `json:"avg_ms"`
AvgCapMS float64 `json:"avg_cap_ms,omitempty"`
AvgEncMS float64 `json:"avg_enc_ms,omitempty"`
FirstEncMS float64 `json:"first_enc_ms,omitempty"`
SteadyEncMS float64 `json:"steady_enc_ms,omitempty"`
MaxMS float64 `json:"max_ms"`
AvgBytes float64 `json:"avg_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
func RunCaptureSmoke(opts CaptureSmokeOptions) []CaptureSmokeResult {
if opts.Frames <= 0 {
opts.Frames = 30
}
if opts.Display < 0 {
opts.Display = 0
}
if opts.FPS > 0 {
SetH264TargetFPS(opts.FPS)
}
if opts.MaxHeight != 0 {
SetMaxResolution(opts.MaxHeight)
}
return []CaptureSmokeResult{
runCaptureH264DirectSmokeCase("dxgi-h264-direct", opts.Display, opts.Frames),
runCaptureSmokeCase("dxgi-preferred", opts.Display, opts.Frames, func(display int) (*image.RGBA, error) {
SetDesktopDuplication(true)
return CaptureDisplayRGBA(display)
}),
runCaptureSmokeCase("bitblt", opts.Display, opts.Frames, func(display int) (*image.RGBA, error) {
SetDesktopDuplication(false)
return CaptureDisplayRGBABitBlt(display)
}),
}
}
func runCaptureH264DirectSmokeCase(backend string, display, frames int) CaptureSmokeResult {
result := CaptureSmokeResult{Backend: backend, Format: "h264", Display: display, Frames: frames}
SetDesktopDuplication(true)
ResetDesktopCapture()
var total time.Duration
var totalCap time.Duration
var totalEnc time.Duration
var firstEnc time.Duration
var steadyEnc time.Duration
var steadyCount int
var max time.Duration
var totalBytes int
maxAttempts := frames * 10
if maxAttempts < frames {
maxAttempts = frames
}
for result.Attempts = 0; result.Attempts < maxAttempts && result.OK < frames; result.Attempts++ {
start := time.Now()
data, width, height, capDur, encDur, used, err := captureDisplayDXGIH264(display, result.OK == 0)
dur := time.Since(start)
total += dur
if used {
totalCap += capDur
totalEnc += encDur
}
if dur > max {
max = dur
}
if err != nil {
if result.OK == 0 {
result.Error = err.Error()
}
continue
}
if !used {
if result.OK == 0 && result.Error == "" {
result.Error = "direct path not used"
}
continue
}
if len(data) == 0 {
if result.OK == 0 {
result.Error = "empty h264 output"
}
continue
}
result.OK++
result.Width = width
result.Height = height
totalBytes += len(data)
result.Error = ""
if result.OK == 1 {
firstEnc = encDur
} else {
steadyEnc += encDur
steadyCount++
}
}
if result.Attempts > 0 {
result.AvgMS = float64(total.Microseconds()) / 1000 / float64(result.Attempts)
result.MaxMS = float64(max.Microseconds()) / 1000
}
if result.OK > 0 {
result.AvgBytes = float64(totalBytes) / float64(result.OK)
result.AvgCapMS = float64(totalCap.Microseconds()) / 1000 / float64(result.OK)
result.AvgEncMS = float64(totalEnc.Microseconds()) / 1000 / float64(result.OK)
result.FirstEncMS = float64(firstEnc.Microseconds()) / 1000
if steadyCount > 0 {
result.SteadyEncMS = float64(steadyEnc.Microseconds()) / 1000 / float64(steadyCount)
}
}
return result
}
func runCaptureSmokeCase(backend string, display, frames int, fn func(int) (*image.RGBA, error)) CaptureSmokeResult {
result := CaptureSmokeResult{Backend: backend, Display: display, Frames: frames}
ResetDesktopCapture()
var total time.Duration
var max time.Duration
for i := 0; i < frames; i++ {
start := time.Now()
img, err := fn(display)
dur := time.Since(start)
total += dur
if dur > max {
max = dur
}
if err != nil {
result.Error = err.Error()
continue
}
if img == nil {
result.Error = "nil image"
continue
}
result.OK++
result.Width = img.Bounds().Dx()
result.Height = img.Bounds().Dy()
PutRGBA(img)
}
if frames > 0 {
result.AvgMS = float64(total.Microseconds()) / 1000 / float64(frames)
result.MaxMS = float64(max.Microseconds()) / 1000
}
return result
}
@@ -0,0 +1,9 @@
//go:build !windows
package capture
import "time"
func captureDisplayDXGIH264(_ int, _ bool) ([]byte, int, int, time.Duration, time.Duration, bool, error) {
return nil, 0, 0, 0, 0, false, nil
}
@@ -2,6 +2,7 @@ package capture
import (
"image"
"image/color"
"os"
"strings"
"sync"
@@ -64,6 +65,34 @@ func TestEncodeH264Frame_WhenUnavailableReturnsError(t *testing.T) {
}
}
func TestEncodeH264Frame_WhenAvailableProducesBytes(t *testing.T) {
if !h264Available() {
t.Skip("h264 is unavailable in this build")
}
t.Cleanup(resetH264Encoder)
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
var got []byte
for frame := 0; frame < 8; frame++ {
for y := 0; y < 64; y++ {
for x := 0; x < 64; x++ {
img.SetRGBA(x, y, color.RGBA{R: uint8(x*3 + frame*7), G: uint8(y * 3), B: uint8(80 + frame*11), A: 255})
}
}
out, err := encodeH264Frame(img)
if err != nil {
t.Fatalf("encodeH264Frame failed: %v", err)
}
if len(out) > 0 {
got = out
break
}
}
if len(got) == 0 {
t.Fatal("expected h264 encoder to produce bytes")
}
}
func TestBlockCodec_UsesEnvWhenNoOverride(t *testing.T) {
prev := os.Getenv(blockCodecEnv)
if err := os.Setenv(blockCodecEnv, "raw"); err != nil {
@@ -0,0 +1,13 @@
//go:build !windows
package capture
import (
"time"
"overlord-client/cmd/agent/wire"
)
func tryBuildDirectH264Frame(_ int) (wire.Frame, time.Duration, time.Duration, bool, error) {
return wire.Frame{}, 0, 0, false, nil
}
@@ -0,0 +1,28 @@
//go:build windows
package capture
import (
"time"
"overlord-client/cmd/agent/webrtcpub"
"overlord-client/cmd/agent/wire"
)
func tryBuildDirectH264Frame(display int) (wire.Frame, time.Duration, time.Duration, bool, error) {
if blockCodec() != "h264" || !useDesktopDuplication() {
return wire.Frame{}, 0, 0, false, nil
}
forceKeyframe := webrtcpub.ConsumeKeyframeRequest()
data, _, _, captureDur, encodeDur, used, err := captureDisplayDXGIH264(display, forceKeyframe)
if !used || err != nil {
return wire.Frame{}, captureDur, encodeDur, used, err
}
if len(data) == 0 {
return wire.Frame{}, captureDur, encodeDur, true, nil
}
now := time.Now()
lastKeyframe.Store(now.UnixNano())
statFullFrames.Add(1)
return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: display, FPS: 0, Format: "h264"}, Data: data}, captureDur, encodeDur, true, nil
}
@@ -1,4 +1,4 @@
//go:build !cgo
//go:build !cgo && !windows
package capture
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
//go:build cgo
//go:build cgo && !windows
package capture
@@ -0,0 +1,207 @@
//go:build windows
package capture
import (
"fmt"
"image"
"time"
)
type H264SmokeResolution struct {
Width int `json:"width"`
Height int `json:"height"`
}
type H264SmokeOptions struct {
Frames int `json:"frames"`
Resolutions []H264SmokeResolution `json:"resolutions"`
FPS []int `json:"fps"`
Providers []string `json:"providers"`
}
type H264SmokeResult struct {
Provider string `json:"provider"`
Input string `json:"input"`
Width int `json:"width"`
Height int `json:"height"`
RequestedFPS int `json:"requested_fps"`
ConfiguredFPS int `json:"configured_fps"`
ConfigureOK bool `json:"configure_ok"`
EncodeOK bool `json:"encode_ok"`
ConfigureMS float64 `json:"configure_ms"`
FramesTried int `json:"frames_tried"`
FramesWithData int `json:"frames_with_data"`
FirstOutputAt int `json:"first_output_at"`
FirstOutputMS float64 `json:"first_output_ms"`
AvgEncodeMS float64 `json:"avg_encode_ms"`
MaxEncodeMS float64 `json:"max_encode_ms"`
AvgBytes float64 `json:"avg_bytes"`
TotalBytes int `json:"total_bytes"`
Error string `json:"error,omitempty"`
}
func DefaultH264SmokeOptions() H264SmokeOptions {
return H264SmokeOptions{
Frames: 30,
Resolutions: []H264SmokeResolution{
{Width: 1280, Height: 720},
{Width: 1920, Height: 1080},
{Width: 2560, Height: 1440},
{Width: 3840, Height: 2160},
},
FPS: []int{30, 60, 120},
Providers: []string{"hardware", "software"},
}
}
func RunH264Smoke(opts H264SmokeOptions) []H264SmokeResult {
opts = normalizeH264SmokeOptions(opts)
out := make([]H264SmokeResult, 0, len(opts.Resolutions)*len(opts.FPS)*len(opts.Providers)*4)
for _, res := range opts.Resolutions {
if res.Width <= 0 || res.Height <= 0 || res.Width%2 != 0 || res.Height%2 != 0 {
out = append(out, H264SmokeResult{Width: res.Width, Height: res.Height, Error: "resolution must be positive and even"})
continue
}
for _, fps := range opts.FPS {
for _, provider := range opts.Providers {
for _, candidate := range h264SmokeCandidates(provider, fps) {
out = append(out, runH264SmokeCase(provider, res.Width, res.Height, fps, candidate, opts.Frames))
}
}
}
}
return out
}
func normalizeH264SmokeOptions(opts H264SmokeOptions) H264SmokeOptions {
def := DefaultH264SmokeOptions()
if opts.Frames <= 0 {
opts.Frames = def.Frames
}
if len(opts.Resolutions) == 0 {
opts.Resolutions = def.Resolutions
}
if len(opts.FPS) == 0 {
opts.FPS = def.FPS
}
if len(opts.Providers) == 0 {
opts.Providers = def.Providers
}
return opts
}
func h264SmokeCandidates(provider string, requestedFPS int) []mfH264Candidate {
if provider == "hardware" {
return hardwareH264Candidates(requestedFPS)
}
return []mfH264Candidate{
{fps: requestedFPS, inputSubtype: MFVideoFormat_NV12, inputFormat: "NV12"},
{fps: requestedFPS, inputSubtype: MFVideoFormat_I420, inputFormat: "I420"},
}
}
func runH264SmokeCase(provider string, width, height, requestedFPS int, candidate mfH264Candidate, frames int) H264SmokeResult {
result := H264SmokeResult{
Provider: provider,
Input: candidate.inputFormat,
Width: width,
Height: height,
RequestedFPS: requestedFPS,
ConfiguredFPS: candidate.fps,
FirstOutputAt: -1,
}
if err := ensureMFStartup(); err != nil {
result.Error = err.Error()
return result
}
transform, hardware, providerName, err := createH264SmokeTransform(provider)
if err != nil {
result.Error = err.Error()
return result
}
result.Provider = providerName
enc := newMFH264EncoderFromTransform(transform, width, height, requestedFPS, candidate.fps, hardware, providerName, candidate.inputSubtype, candidate.inputFormat)
t0 := time.Now()
if err := enc.configure(); err != nil {
result.ConfigureMS = float64(time.Since(t0).Microseconds()) / 1000
result.Error = err.Error()
enc.Close()
return result
}
result.ConfigureOK = true
result.ConfigureMS = float64(time.Since(t0).Microseconds()) / 1000
defer enc.Close()
img := image.NewRGBA(image.Rect(0, 0, width, height))
totalEncode := time.Duration(0)
maxEncode := time.Duration(0)
firstStart := time.Now()
for frame := 0; frame < frames; frame++ {
fillH264SmokeFrame(img, frame)
encStart := time.Now()
data, err := enc.Encode(img)
encDur := time.Since(encStart)
result.FramesTried++
totalEncode += encDur
if encDur > maxEncode {
maxEncode = encDur
}
if err != nil {
result.Error = err.Error()
return result
}
if len(data) > 0 {
result.FramesWithData++
result.TotalBytes += len(data)
if result.FirstOutputAt < 0 {
result.FirstOutputAt = frame + 1
result.FirstOutputMS = float64(time.Since(firstStart).Microseconds()) / 1000
}
}
}
if result.FramesTried > 0 {
result.AvgEncodeMS = float64(totalEncode.Microseconds()) / 1000 / float64(result.FramesTried)
result.MaxEncodeMS = float64(maxEncode.Microseconds()) / 1000
}
if result.FramesWithData > 0 {
result.EncodeOK = true
result.AvgBytes = float64(result.TotalBytes) / float64(result.FramesWithData)
}
if !result.EncodeOK {
result.Error = "configured but produced no output"
}
return result
}
func createH264SmokeTransform(provider string) (*mfTransform, bool, string, error) {
switch provider {
case "hardware":
transform, err := activateHardwareH264MFT()
if err != nil {
return nil, false, "hardware", err
}
return transform, true, "hardware", nil
case "software":
return createSoftwareH264Transform()
default:
return nil, false, provider, fmt.Errorf("unknown provider %q", provider)
}
}
func fillH264SmokeFrame(img *image.RGBA, frame int) {
width := img.Bounds().Dx()
height := img.Bounds().Dy()
for y := 0; y < height; y++ {
row := img.Pix[y*img.Stride:]
for x := 0; x < width; x++ {
i := x * 4
row[i+0] = byte((x + frame*7) & 0xff)
row[i+1] = byte((y + frame*5) & 0xff)
row[i+2] = byte((x/2 + y/3 + frame*11) & 0xff)
row[i+3] = 255
}
}
}
@@ -0,0 +1,20 @@
//go:build windows && !cgo
package capture
import "fmt"
import "unsafe"
func newNativeH264Encoder(width, height, fps int) (h264FrameEncoder, error) {
return nil, fmt.Errorf("native NVENC D3D11 requires cgo for %dx%d@%dfps", width, height, fps)
}
func nativeH264AvailabilityDetail() string {
return ""
}
func encodeNativeH264D3D11Texture(device, texture unsafe.Pointer, inputWidth, inputHeight, encodeWidth, encodeHeight, fps int, dxgiFormat uint32, forceIDR bool) ([]byte, error) {
return nil, fmt.Errorf("native NVENC D3D11 texture encode requires cgo for input=%dx%d output=%dx%d@%dfps", inputWidth, inputHeight, encodeWidth, encodeHeight, fps)
}
func resetNativeH264D3D11TextureEncoder() {}
@@ -0,0 +1,38 @@
//go:build !windows || !cgo
package capture
type NVENCD3D11SmokeOptions struct {
Width int `json:"width"`
Height int `json:"height"`
FPS int `json:"fps"`
Frames int `json:"frames"`
Bitrate int `json:"bitrate"`
}
type NVENCD3D11SmokeResult struct {
OK bool `json:"ok"`
Width int `json:"width"`
Height int `json:"height"`
FPS int `json:"fps"`
Frames int `json:"frames"`
FirstMS float64 `json:"first_ms"`
AvgMS float64 `json:"avg_ms"`
TotalBytes uint64 `json:"total_bytes"`
Error string `json:"error,omitempty"`
Stage int `json:"stage,omitempty"`
HRESULT uint32 `json:"hresult,omitempty"`
NVStatus int `json:"nv_status,omitempty"`
Message string `json:"message,omitempty"`
}
func RunNVENCD3D11Smoke(opts NVENCD3D11SmokeOptions) NVENCD3D11SmokeResult {
return NVENCD3D11SmokeResult{
Width: opts.Width,
Height: opts.Height,
FPS: opts.FPS,
Frames: opts.Frames,
Error: "NVENC D3D11 smoke requires Windows with cgo enabled",
Message: "NVENC D3D11 smoke requires Windows with cgo enabled",
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
//go:build !windows
package capture
type NVENCSmokeResult struct {
Available bool `json:"available"`
DLL string `json:"dll"`
APIMajor int `json:"api_major"`
APIMinor int `json:"api_minor"`
RawAPI uint32 `json:"raw_api"`
Error string `json:"error,omitempty"`
}
func RunNVENCSmoke() NVENCSmokeResult {
return NVENCSmokeResult{DLL: "nvEncodeAPI64.dll", Error: "NVENC smoke is only supported on Windows"}
}
@@ -0,0 +1,44 @@
//go:build windows
package capture
import (
"fmt"
"syscall"
"unsafe"
)
type NVENCSmokeResult struct {
Available bool `json:"available"`
DLL string `json:"dll"`
APIMajor int `json:"api_major"`
APIMinor int `json:"api_minor"`
RawAPI uint32 `json:"raw_api"`
Error string `json:"error,omitempty"`
}
func RunNVENCSmoke() NVENCSmokeResult {
result := NVENCSmokeResult{DLL: "nvEncodeAPI64.dll"}
dll := syscall.NewLazyDLL(result.DLL)
proc := dll.NewProc("NvEncodeAPIGetMaxSupportedVersion")
if err := dll.Load(); err != nil {
result.Error = err.Error()
return result
}
if err := proc.Find(); err != nil {
result.Error = err.Error()
return result
}
var raw uint32
status, _, _ := proc.Call(uintptr(unsafe.Pointer(&raw)))
if status != 0 {
result.Error = fmt.Sprintf("NvEncodeAPIGetMaxSupportedVersion failed status=%d", status)
return result
}
result.Available = true
result.RawAPI = raw
result.APIMajor = int(raw >> 4)
result.APIMinor = int(raw & 0xf)
return result
}
@@ -53,6 +53,8 @@ const (
d3d11UsageStaging = 3
d3d11CpuAccessRead = 0x20000
d3d11MapRead = 1
dxgiFormatR8G8B8A8UNorm = 28
dxgiFormatB8G8R8A8UNorm = 87
dxgiModeRotationUnspecified = 0
dxgiModeRotationIdentity = 1
dxgiModeRotationRotate90 = 2
@@ -595,6 +597,9 @@ func (d *idxgiOutputDuplication) ReleaseFrame() uintptr {
var (
desktopDuplicationEnabled atomic.Bool
dxgiState = &duplicationState{}
directH264ActiveOnce sync.Once
directH264ScaleOnce sync.Once
directH264WarnOnce sync.Once
)
type duplicationState struct {
@@ -634,6 +639,10 @@ func captureDisplayDXGI(display int) (*image.RGBA, error) {
return dxgiState.capture(display)
}
func captureDisplayDXGIH264(display int, forceKeyframe bool) ([]byte, int, int, time.Duration, time.Duration, bool, error) {
return dxgiState.captureH264(display, forceKeyframe)
}
func (s *duplicationState) reset() {
s.mu.Lock()
defer s.mu.Unlock()
@@ -649,6 +658,7 @@ func (s *duplicationState) closeLocked() {
s.staging.Release()
s.staging = nil
}
resetNativeH264D3D11TextureEncoder()
if s.context != nil {
s.context.Release()
s.context = nil
@@ -813,6 +823,108 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) {
return img, nil
}
func (s *duplicationState) captureH264(display int, forceKeyframe bool) ([]byte, int, int, time.Duration, time.Duration, bool, error) {
capStart := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if !useDesktopDuplication() || cursorCaptureEnabled.Load() {
return nil, 0, 0, 0, 0, false, errors.New("direct h264 requires DXGI duplication and cursor capture disabled")
}
if err := s.ensure(display); err != nil {
s.lastFail = time.Now()
return nil, 0, 0, 0, 0, false, err
}
width := int(s.desc.ModeDesc.Width)
height := int(s.desc.ModeDesc.Height)
if width <= 0 || height <= 0 || width%2 != 0 || height%2 != 0 {
return nil, 0, 0, 0, 0, false, fmt.Errorf("direct h264 requires positive even dimensions, got %dx%d", width, height)
}
if s.desc.Rotation != dxgiModeRotationIdentity && s.desc.Rotation != dxgiModeRotationUnspecified {
return nil, 0, 0, 0, 0, false, fmt.Errorf("direct h264 requires identity rotation, got %d", s.desc.Rotation)
}
encodeW, encodeH := width, height
if scale := effectiveScale(width, height); scale != 1 {
encodeW = int(float64(width) * scale)
encodeH = int(float64(height) * scale)
encodeW -= encodeW % 2
encodeH -= encodeH % 2
if encodeW <= 0 || encodeH <= 0 {
return nil, 0, 0, 0, 0, false, fmt.Errorf("direct h264 computed invalid scaled output %dx%d", encodeW, encodeH)
}
directH264ScaleOnce.Do(func() {
log.Printf("capture: direct DXGI->NVENC h264 using NVENC scale=%.2f input=%dx%d output=%dx%d", scale, width, height, encodeW, encodeH)
})
}
var info dxgiOutDuplFrameInfo
var resource *iunknown
hr := s.dup.AcquireNextFrame(5, &info, &resource)
if hr == dxgiErrorWaitTimeout {
return nil, encodeW, encodeH, time.Since(capStart), 0, true, nil
}
if hr == dxgiErrorAccessLost {
s.closeLocked()
return nil, 0, 0, 0, 0, false, errors.New("dxgi: access lost")
}
if hr == dxgiErrorDeviceRemoved || hr == dxgiErrorDeviceReset {
s.closeLocked()
return nil, 0, 0, 0, 0, false, fmt.Errorf("dxgi: device lost 0x%x", hr)
}
if hr != S_OK {
return nil, 0, 0, 0, 0, false, fmt.Errorf("dxgi: acquire frame failed 0x%x", hr)
}
if resource != nil {
defer resource.Release()
}
if resource == nil {
return nil, 0, 0, 0, 0, false, errors.New("dxgi: acquired nil resource")
}
defer s.dup.ReleaseFrame()
var tex *d3d11Texture2D
hr = resource.QueryInterface(&IID_ID3D11Texture2D, unsafe.Pointer(&tex))
if hr != S_OK || tex == nil {
return nil, 0, 0, 0, 0, false, fmt.Errorf("dxgi: acquired resource is not ID3D11Texture2D 0x%x", hr)
}
defer tex.Release()
var srcDesc d3d11Texture2DDesc
tex.GetDesc(&srcDesc)
if srcDesc.Width == 0 || srcDesc.Height == 0 {
return nil, 0, 0, 0, 0, false, errors.New("dxgi: acquired texture has invalid dimensions")
}
if int(srcDesc.Width) != width || int(srcDesc.Height) != height {
return nil, 0, 0, 0, 0, false, fmt.Errorf("dxgi: texture size %dx%d does not match duplication %dx%d", srcDesc.Width, srcDesc.Height, width, height)
}
if srcDesc.Format != dxgiFormatB8G8R8A8UNorm && srcDesc.Format != dxgiFormatR8G8B8A8UNorm {
directH264WarnOnce.Do(func() {
log.Printf("capture: direct DXGI->NVENC h264 unavailable: unsupported DXGI texture format %d; using readback path", srcDesc.Format)
})
return nil, 0, 0, 0, 0, false, fmt.Errorf("direct h264 unsupported DXGI texture format %d", srcDesc.Format)
}
captureDur := time.Since(capStart)
fps := activeH264FPS()
encStart := time.Now()
out, err := encodeNativeH264D3D11Texture(unsafe.Pointer(s.device), unsafe.Pointer(tex), width, height, encodeW, encodeH, fps, srcDesc.Format, forceKeyframe)
encodeDur := time.Since(encStart)
if err != nil {
directH264WarnOnce.Do(func() {
log.Printf("capture: direct DXGI->NVENC h264 failed for %dx%d@%dfps format=%d: %v; using readback path", width, height, fps, srcDesc.Format, err)
})
return nil, 0, 0, 0, 0, false, err
}
if len(out) == 0 {
return nil, encodeW, encodeH, captureDur, encodeDur, true, nil
}
directH264ActiveOnce.Do(func() {
log.Printf("capture: direct DXGI->NVENC h264 path active input=%dx%d output=%dx%d fps=%d dxgi_format=%d", width, height, encodeW, encodeH, fps, srcDesc.Format)
})
return out, encodeW, encodeH, captureDur, encodeDur, true, nil
}
func (s *duplicationState) composeFrame(base *image.RGBA, nativeW, nativeH int) *image.RGBA {
if base == nil {
return nil
@@ -814,6 +814,18 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
capture.SetMaxResolution(maxH)
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "desktop_set_fps":
payload, _ := envelope["payload"].(map[string]interface{})
fps := 120
if payload != nil {
if v, ok := payloadInt(payload, "fps"); ok {
fps = v
}
}
fps = SetDesktopTargetFPS(fps)
log.Printf("desktop: set target fps=%d", fps)
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "clipboard_sync_start":
payload, _ := envelope["payload"].(map[string]interface{})
source := "rd"
+37 -5
View File
@@ -6,12 +6,14 @@ import (
"overlord-client/cmd/agent/capture"
rt "overlord-client/cmd/agent/runtime"
"sync"
"sync/atomic"
"time"
)
var (
persistedDisplayValue int
persistedDisplayMu sync.Mutex
desktopTargetFPS atomic.Int64
)
func persistDisplaySelection(display int) {
@@ -28,21 +30,26 @@ func GetPersistedDisplay() int {
func DesktopStart(ctx context.Context, env *rt.Env) error {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
interval, fps := streamInterval("OVERLORD_DESKTOP_MAX_FPS", 120)
if fps < 60 {
fps = 60
interval = time.Second / time.Duration(fps)
}
fps := activeDesktopTargetFPS()
interval := time.Second / time.Duration(fps)
capture.SetH264TargetFPS(fps)
log.Printf("desktop: starting stream (target fps %d)", fps)
ticker := time.NewTicker(interval)
defer ticker.Stop()
currentFPS := fps
for {
select {
case <-ctx.Done():
log.Printf("desktop: stopping stream")
return nil
case <-ticker.C:
fps := activeDesktopTargetFPS()
if fps != currentFPS {
currentFPS = fps
capture.SetH264TargetFPS(fps)
ticker.Reset(time.Second / time.Duration(fps))
log.Printf("desktop: target fps changed to %d", fps)
}
if err := capture.Now(ctx, env); err != nil {
if ctx.Err() != nil {
log.Printf("desktop: stopping stream")
@@ -54,6 +61,31 @@ func DesktopStart(ctx context.Context, env *rt.Env) error {
}
}
func activeDesktopTargetFPS() int {
if fps := int(desktopTargetFPS.Load()); fps > 0 {
return fps
}
_, fps := streamInterval("OVERLORD_DESKTOP_MAX_FPS", 120)
return SetDesktopTargetFPS(fps)
}
func SetDesktopTargetFPS(fps int) int {
fps = clampDesktopTargetFPS(fps)
desktopTargetFPS.Store(int64(fps))
capture.SetH264TargetFPS(fps)
return fps
}
func clampDesktopTargetFPS(fps int) int {
if fps < 1 {
return 1
}
if fps > 240 {
return 240
}
return fps
}
func DesktopSelect(ctx context.Context, env *rt.Env, display int) error {
prev := env.SelectedDisplay
maxDisplays := capture.MonitorCount()
@@ -61,7 +61,7 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
SDPFmtpLine: h264SDPFmtpLine,
},
PayloadType: 102,
}, webrtc.RTPCodecTypeVideo); err != nil {
@@ -46,6 +46,7 @@ func registerVideoWriter(kind Kind, id string, w VideoWriter) {
entry.video = w
bucket[id] = entry
writersMu.Unlock()
RequestKeyframe()
}
func registerAudioWriter(kind Kind, id string, w AudioWriter) {
@@ -37,9 +37,32 @@ func drainRTCP(sender *webrtc.RTPSender) {
for _, p := range packets {
switch p.(type) {
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
handleRTCPKeyframeFeedback()
}
}
}
}
const h264SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034"
var rtcpKeyframeLogOnce sync.Once
func handleRTCPKeyframeFeedback() {
if !honorRTCPKeyframes() {
rtcpKeyframeLogOnce.Do(func() {
log.Printf("webrtcpub: ignoring RTCP video keyframe requests by default; set OVERLORD_WEBRTC_RTCP_KEYFRAMES=true to honor them")
})
return
}
RequestKeyframe()
}
}
}
func honorRTCPKeyframes() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_WEBRTC_RTCP_KEYFRAMES"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
@@ -86,7 +109,7 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) {
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
SDPFmtpLine: h264SDPFmtpLine,
},
PayloadType: 102,
}, webrtc.RTPCodecTypeVideo); err != nil {
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "fmt"
func main() {
fmt.Println("mf-h264-smoke is only supported on Windows")
}
@@ -0,0 +1,234 @@
//go:build windows
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"overlord-client/cmd/agent/capture"
)
func main() {
var frames int
var captureFrames int
var captureDisplay int
var captureFPS int
var captureMaxHeight int
var jsonOut bool
var includeCapture bool
var includeNVENC bool
var includeNVENCD3D11 bool
var resArg string
var fpsArg string
var providersArg string
flag.IntVar(&frames, "frames", 30, "frames to feed each encoder case")
flag.IntVar(&captureFrames, "capture-frames", 30, "frames to capture for each capture backend")
flag.IntVar(&captureDisplay, "display", 0, "display index for capture smoke")
flag.IntVar(&captureFPS, "capture-fps", 0, "target FPS for direct H.264 capture smoke")
flag.IntVar(&captureMaxHeight, "capture-max-height", 0, "max capture output height; use -1 to bypass the resolution cap")
flag.BoolVar(&jsonOut, "json", false, "print JSON instead of a table")
flag.BoolVar(&includeCapture, "capture", false, "also time DXGI-preferred and BitBlt capture backends")
flag.BoolVar(&includeNVENC, "nvenc", false, "also probe the native NVIDIA NVENC API and nvidia-smi")
flag.BoolVar(&includeNVENCD3D11, "nvenc-d3d11", false, "also encode test frames through NVENC using a D3D11 NV12 texture")
flag.StringVar(&resArg, "res", "1280x720,1920x1080,2560x1440,3840x2160", "comma-separated resolutions")
flag.StringVar(&fpsArg, "fps", "30,60,120", "comma-separated requested FPS values")
flag.StringVar(&providersArg, "providers", "hardware,software", "comma-separated providers: hardware,software")
flag.Parse()
opts := capture.DefaultH264SmokeOptions()
opts.Frames = frames
opts.Resolutions = parseResolutions(resArg)
opts.FPS = parseInts(fpsArg)
opts.Providers = parseStrings(providersArg)
results := capture.RunH264Smoke(opts)
var captureResults []capture.CaptureSmokeResult
if includeCapture {
captureResults = capture.RunCaptureSmoke(capture.CaptureSmokeOptions{Display: captureDisplay, Frames: captureFrames, FPS: captureFPS, MaxHeight: captureMaxHeight})
}
var nvencResult *capture.NVENCSmokeResult
var nvidiaSMI []string
if includeNVENC {
result := capture.RunNVENCSmoke()
nvencResult = &result
nvidiaSMI = queryNvidiaSMI()
}
var nvencD3D11Results []capture.NVENCD3D11SmokeResult
if includeNVENCD3D11 {
nvencD3D11Results = runNVENCD3D11SmokeCases(opts.Resolutions, opts.FPS, frames)
if !includeNVENC {
nvidiaSMI = queryNvidiaSMI()
}
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
payload := struct {
H264 []capture.H264SmokeResult `json:"h264"`
Capture []capture.CaptureSmokeResult `json:"capture,omitempty"`
NVENC *capture.NVENCSmokeResult `json:"nvenc,omitempty"`
NVENCD3D11 []capture.NVENCD3D11SmokeResult `json:"nvenc_d3d11,omitempty"`
Nvidia []string `json:"nvidia_smi,omitempty"`
}{H264: results, Capture: captureResults, NVENC: nvencResult, NVENCD3D11: nvencD3D11Results, Nvidia: nvidiaSMI}
if err := enc.Encode(payload); err != nil {
fmt.Fprintf(os.Stderr, "json encode failed: %v\n", err)
os.Exit(1)
}
return
}
printTable(results)
if includeCapture {
fmt.Println()
printCaptureTable(captureResults)
}
if includeNVENC {
fmt.Println()
printNVENCSummary(nvencResult, nvidiaSMI)
}
if includeNVENCD3D11 {
fmt.Println()
printNVENCD3D11Table(nvencD3D11Results)
if !includeNVENC {
for _, line := range nvidiaSMI {
fmt.Println("nvidia-smi:", line)
}
}
}
}
func parseResolutions(raw string) []capture.H264SmokeResolution {
parts := parseStrings(raw)
out := make([]capture.H264SmokeResolution, 0, len(parts))
for _, part := range parts {
fields := strings.Split(strings.ToLower(part), "x")
if len(fields) != 2 {
continue
}
w, werr := strconv.Atoi(strings.TrimSpace(fields[0]))
h, herr := strconv.Atoi(strings.TrimSpace(fields[1]))
if werr == nil && herr == nil {
out = append(out, capture.H264SmokeResolution{Width: w, Height: h})
}
}
return out
}
func parseInts(raw string) []int {
parts := parseStrings(raw)
out := make([]int, 0, len(parts))
for _, part := range parts {
if v, err := strconv.Atoi(part); err == nil {
out = append(out, v)
}
}
return out
}
func parseStrings(raw string) []string {
fields := strings.Split(raw, ",")
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field != "" {
out = append(out, field)
}
}
return out
}
func printTable(results []capture.H264SmokeResult) {
fmt.Printf("%-18s %-5s %9s %7s %7s %8s %9s %8s %7s %9s %9s %s\n",
"provider", "input", "size", "reqfps", "cfgfps", "config", "first_ms", "avg_ms", "frames", "avg_bytes", "total", "error")
for _, r := range results {
status := "fail"
if r.ConfigureOK && r.EncodeOK {
status = "ok"
} else if r.ConfigureOK {
status = "no-data"
}
fmt.Printf("%-18s %-5s %4dx%-4d %7d %7d %8s %9.2f %8.2f %7d %9.0f %9d %s\n",
r.Provider, r.Input, r.Width, r.Height, r.RequestedFPS, r.ConfiguredFPS, status,
r.FirstOutputMS, r.AvgEncodeMS, r.FramesTried, r.AvgBytes, r.TotalBytes, r.Error)
}
}
func printCaptureTable(results []capture.CaptureSmokeResult) {
fmt.Printf("%-18s %-6s %7s %7s %7s %9s %9s %9s %9s %9s %9s %9s %s\n", "backend", "format", "display", "ok", "attempt", "size", "avg_ms", "cap_ms", "enc_ms", "first_enc", "steady", "avg_bytes", "error")
for _, r := range results {
fmt.Printf("%-18s %-6s %7d %7d %7d %4dx%-4d %9.2f %9.2f %9.2f %9.2f %9.2f %9.0f %s\n",
r.Backend, r.Format, r.Display, r.OK, r.Attempts, r.Width, r.Height, r.AvgMS, r.AvgCapMS, r.AvgEncMS, r.FirstEncMS, r.SteadyEncMS, r.AvgBytes, r.Error)
}
}
func printNVENCSummary(result *capture.NVENCSmokeResult, smi []string) {
if result == nil {
return
}
status := "unavailable"
if result.Available {
status = fmt.Sprintf("available api=%d.%d raw=%d", result.APIMajor, result.APIMinor, result.RawAPI)
}
fmt.Printf("nvenc: %s dll=%s", status, result.DLL)
if result.Error != "" {
fmt.Printf(" error=%s", result.Error)
}
fmt.Println()
for _, line := range smi {
fmt.Println("nvidia-smi:", line)
}
}
func runNVENCD3D11SmokeCases(resolutions []capture.H264SmokeResolution, fpsValues []int, frames int) []capture.NVENCD3D11SmokeResult {
out := make([]capture.NVENCD3D11SmokeResult, 0, len(resolutions)*len(fpsValues))
for _, res := range resolutions {
for _, fps := range fpsValues {
out = append(out, capture.RunNVENCD3D11Smoke(capture.NVENCD3D11SmokeOptions{
Width: res.Width,
Height: res.Height,
FPS: fps,
Frames: frames,
}))
}
}
return out
}
func printNVENCD3D11Table(results []capture.NVENCD3D11SmokeResult) {
fmt.Printf("%-12s %9s %7s %7s %9s %8s %9s %9s %s\n",
"nvenc-d3d11", "size", "fps", "frames", "first_ms", "avg_ms", "avg_bytes", "total", "error")
for _, r := range results {
status := "fail"
if r.OK {
status = "ok"
}
avgBytes := float64(0)
if r.Frames > 0 {
avgBytes = float64(r.TotalBytes) / float64(r.Frames)
}
fmt.Printf("%-12s %4dx%-4d %7d %7d %9.2f %8.2f %9.0f %9d %s\n",
status, r.Width, r.Height, r.FPS, r.Frames, r.FirstMS, r.AvgMS, avgBytes, r.TotalBytes, r.Error)
}
}
func queryNvidiaSMI() []string {
cmd := exec.Command("nvidia-smi", "--query-gpu=name,driver_version,encoder.stats.sessionCount,encoder.stats.averageFps,encoder.stats.averageLatency", "--format=csv,noheader")
out, err := cmd.Output()
if err != nil {
return []string{err.Error()}
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
const cursorCtrl = document.getElementById("cursorCtrl");
const duplicationCtrl = document.getElementById("duplicationCtrl");
const resolutionSelect = document.getElementById("resolutionSelect");
const targetFpsSelect = document.getElementById("targetFpsSelect");
const smoothingSlider = document.getElementById("smoothingSlider");
const smoothingValue = document.getElementById("smoothingValue");
const qualitySlider = document.getElementById("qualitySlider");
@@ -1101,14 +1102,28 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
const codec = q >= 100 ? "raw" : (prefersH264 ? "h264" : "jpeg");
console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec);
setCodecModeLabel(codec, "requested");
if (codec === "h264") {
ensureDuplicationForH264();
}
sendCmd("desktop_set_quality", { quality: q, codec });
}
function ensureDuplicationForH264() {
if (!duplicationCtrl || duplicationCtrl.disabled) return;
const isWindows = clientOs.includes("windows") || clientOs.includes("win");
if (!isWindows || duplicationCtrl.checked) return;
duplicationCtrl.checked = true;
sendCmd("desktop_set_duplication", { enabled: true });
}
if (codecH264) {
codecH264.addEventListener("change", function () {
resetH264SessionState();
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
if (prefersH264) {
ensureDuplicationForH264();
}
if (!prefersH264) {
destroyVideoDecoder();
h264LowFpsStreak = 0;
@@ -1127,12 +1142,29 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}
}
function selectedTargetFps() {
const fps = Number(targetFpsSelect?.value || 120);
return Number.isFinite(fps) ? Math.max(1, Math.min(240, Math.floor(fps))) : 120;
}
function pushTargetFps() {
const fps = selectedTargetFps();
console.debug("rd: pushTargetFps fps=", fps);
sendCmd("desktop_set_fps", { fps });
}
if (resolutionSelect) {
resolutionSelect.addEventListener("change", function () {
pushResolution();
});
}
if (targetFpsSelect) {
targetFpsSelect.addEventListener("change", function () {
pushTargetFps();
});
}
displaySelect.addEventListener("change", function () {
console.debug("rd: select display", displaySelect.value);
sendCmd("desktop_select_display", {
@@ -1163,6 +1195,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
display: displaySelect?.value ?? "",
quality: qualitySlider?.value ?? "",
resolution: resolutionSelect?.value ?? "",
targetFps: targetFpsSelect?.value ?? "",
prefersH264,
duplication: !!duplicationCtrl?.checked,
clientOs,
@@ -1182,6 +1215,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
if (prefersH264) {
ensureDuplicationForH264();
}
pushTargetFps();
pushResolution();
desiredStreaming = true;
lastFrameAt = 0;
+16
View File
@@ -176,6 +176,22 @@
<option value="relayed">WebRTC Relayed</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2">
<label class="text-sm text-slate-300 w-20" for="targetFpsSelect">Max FPS</label>
<select
id="targetFpsSelect"
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
title="Target capture and encoder frame rate"
>
<option value="30">30 fps</option>
<option value="60">60 fps</option>
<option value="90">90 fps</option>
<option value="120" selected>120 fps</option>
<option value="144">144 fps</option>
<option value="165">165 fps</option>
<option value="240">240 fps</option>
</select>
</div>
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2 text-sm text-slate-200" title="Prefer H.264 when available; disable to force JPEG">
<input type="checkbox" id="codecH264" checked /> H.264
+1
View File
@@ -119,6 +119,7 @@ export type CommandType =
| "desktop_select_display"
| "desktop_enable_mouse"
| "desktop_enable_keyboard"
| "desktop_set_fps"
| "darwin_request_permissions"
| "webrtc_publish"
| "webrtc_stop"
@@ -168,6 +168,7 @@ export const rdStreamingState = new Map<string, {
codec: string;
duplication: boolean;
maxHeight: number;
maxFps: number;
lastFps: number;
}>();
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
@@ -181,6 +182,15 @@ function pruneRdInputPending(now = Date.now()) {
}
}
function defaultRdStreamingState() {
return { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, maxFps: 120, lastFps: 0 };
}
function clampDesktopFps(value: unknown): number {
const fps = Math.floor(Number(value) || 120);
return Math.max(1, Math.min(240, fps));
}
function recordRdInput(commandId: string, clientId: string, kind: string) {
pruneRdInputPending();
rdInputPending.set(commandId, { clientId, sentAt: Date.now(), kind });
@@ -325,7 +335,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
return;
}
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
const state = rdStreamingState.get(clientId) || defaultRdStreamingState();
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
switch (payload.type) {
@@ -366,6 +376,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
whepPath: `/api/webrtc/${streamPath}/whep`,
});
}
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
sendDesktopCommand(target, "desktop_start", {});
state.isStreaming = true;
rdStreamingState.set(clientId, state);
@@ -489,6 +500,16 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
}
break;
}
case "desktop_set_fps": {
const newMaxFps = clampDesktopFps((payload as any).fps);
if (state.maxFps !== newMaxFps) {
sendDesktopCommand(target, "desktop_set_fps", { fps: newMaxFps });
state.maxFps = newMaxFps;
rdStreamingState.set(clientId, state);
logger.debug(`[rd] set target fps=${newMaxFps}`);
}
break;
}
case "desktop_set_duplication": {
const enabled = !!payload.enabled;
if (state.duplication !== enabled) {
@@ -648,7 +669,7 @@ function handleRemoteDesktopFrame(payload: any) {
const clientId = payload.clientId as string;
const header = payload.header;
const bytes = payload.data as Uint8Array;
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
const state = rdStreamingState.get(clientId) || defaultRdStreamingState();
if (!state.isStreaming) {
state.isStreaming = true;
}
@@ -667,7 +688,7 @@ function handleRemoteDesktopFrame(payload: any) {
function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header?: any): boolean {
const frameFps = Number(header?.fps) || 0;
if (frameFps > 0) {
const state = rdStreamingState.get(clientId) || { isStreaming: true, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
const state = rdStreamingState.get(clientId) || { ...defaultRdStreamingState(), isStreaming: true };
state.lastFps = frameFps;
rdStreamingState.set(clientId, state);
}
+1 -1
View File
@@ -29,7 +29,7 @@ set LOG_LEVEL=debug
start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
timeout /t 3 /nobreak >nul
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set OVERLORD_CAPTURE_METRICS=true && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
echo Done. Terminals stay open (/k) for logs.
endlocal