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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,12 +37,35 @@ func drainRTCP(sender *webrtc.RTPSender) {
|
||||
for _, p := range packets {
|
||||
switch p.(type) {
|
||||
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
|
||||
RequestKeyframe()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
type h264TrackWriter struct {
|
||||
t *webrtc.TrackLocalStaticSample
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+4667
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user