NVENC support
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user