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