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)
|
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)
|
img, err := safeCaptureDisplay(display)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
img, err = safeCaptureDisplay(display)
|
img, err = safeCaptureDisplay(display)
|
||||||
@@ -222,6 +236,74 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
|
|||||||
return err
|
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
|
// captureAllDisplaysAndSend stitches all monitors into a single image and sends
|
||||||
// it as the initial frame. Used on Windows so the dashboard thumbnail shows
|
// it as the initial frame. Used on Windows so the dashboard thumbnail shows
|
||||||
// every monitor, not just the primary one.
|
// every monitor, not just the primary one.
|
||||||
@@ -391,6 +473,8 @@ var (
|
|||||||
fpsCount atomic.Int64
|
fpsCount atomic.Int64
|
||||||
fpsLatest atomic.Int64
|
fpsLatest atomic.Int64
|
||||||
lastFrameLog atomic.Int64
|
lastFrameLog atomic.Int64
|
||||||
|
metricsOnce sync.Once
|
||||||
|
metricsEnabled bool
|
||||||
lastKeyframe atomic.Int64
|
lastKeyframe atomic.Int64
|
||||||
fullNextFrames atomic.Int64
|
fullNextFrames atomic.Int64
|
||||||
|
|
||||||
@@ -474,6 +558,9 @@ func frameFPS(now time.Time) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldLogFrame(now time.Time) bool {
|
func shouldLogFrame(now time.Time) bool {
|
||||||
|
if !captureMetricsEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
last := time.Unix(0, lastFrameLog.Load())
|
last := time.Unix(0, lastFrameLog.Load())
|
||||||
if now.Sub(last) >= frameLogInterval {
|
if now.Sub(last) >= frameLogInterval {
|
||||||
lastFrameLog.Store(now.UnixNano())
|
lastFrameLog.Store(now.UnixNano())
|
||||||
@@ -482,6 +569,23 @@ func shouldLogFrame(now time.Time) bool {
|
|||||||
return false
|
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) {
|
func requestFullFrames(count int) {
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
return
|
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 (
|
import (
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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) {
|
func TestBlockCodec_UsesEnvWhenNoOverride(t *testing.T) {
|
||||||
prev := os.Getenv(blockCodecEnv)
|
prev := os.Getenv(blockCodecEnv)
|
||||||
if err := os.Setenv(blockCodecEnv, "raw"); err != nil {
|
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
|
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
|
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
|
d3d11UsageStaging = 3
|
||||||
d3d11CpuAccessRead = 0x20000
|
d3d11CpuAccessRead = 0x20000
|
||||||
d3d11MapRead = 1
|
d3d11MapRead = 1
|
||||||
|
dxgiFormatR8G8B8A8UNorm = 28
|
||||||
|
dxgiFormatB8G8R8A8UNorm = 87
|
||||||
dxgiModeRotationUnspecified = 0
|
dxgiModeRotationUnspecified = 0
|
||||||
dxgiModeRotationIdentity = 1
|
dxgiModeRotationIdentity = 1
|
||||||
dxgiModeRotationRotate90 = 2
|
dxgiModeRotationRotate90 = 2
|
||||||
@@ -595,6 +597,9 @@ func (d *idxgiOutputDuplication) ReleaseFrame() uintptr {
|
|||||||
var (
|
var (
|
||||||
desktopDuplicationEnabled atomic.Bool
|
desktopDuplicationEnabled atomic.Bool
|
||||||
dxgiState = &duplicationState{}
|
dxgiState = &duplicationState{}
|
||||||
|
directH264ActiveOnce sync.Once
|
||||||
|
directH264ScaleOnce sync.Once
|
||||||
|
directH264WarnOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
type duplicationState struct {
|
type duplicationState struct {
|
||||||
@@ -634,6 +639,10 @@ func captureDisplayDXGI(display int) (*image.RGBA, error) {
|
|||||||
return dxgiState.capture(display)
|
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() {
|
func (s *duplicationState) reset() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -649,6 +658,7 @@ func (s *duplicationState) closeLocked() {
|
|||||||
s.staging.Release()
|
s.staging.Release()
|
||||||
s.staging = nil
|
s.staging = nil
|
||||||
}
|
}
|
||||||
|
resetNativeH264D3D11TextureEncoder()
|
||||||
if s.context != nil {
|
if s.context != nil {
|
||||||
s.context.Release()
|
s.context.Release()
|
||||||
s.context = nil
|
s.context = nil
|
||||||
@@ -813,6 +823,108 @@ func (s *duplicationState) capture(display int) (*image.RGBA, error) {
|
|||||||
return img, nil
|
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 {
|
func (s *duplicationState) composeFrame(base *image.RGBA, nativeW, nativeH int) *image.RGBA {
|
||||||
if base == nil {
|
if base == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -814,6 +814,18 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
capture.SetMaxResolution(maxH)
|
capture.SetMaxResolution(maxH)
|
||||||
sendCommandResultSafe(env, cmdID, true, "")
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
return nil
|
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":
|
case "clipboard_sync_start":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
source := "rd"
|
source := "rd"
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"overlord-client/cmd/agent/capture"
|
"overlord-client/cmd/agent/capture"
|
||||||
rt "overlord-client/cmd/agent/runtime"
|
rt "overlord-client/cmd/agent/runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
persistedDisplayValue int
|
persistedDisplayValue int
|
||||||
persistedDisplayMu sync.Mutex
|
persistedDisplayMu sync.Mutex
|
||||||
|
desktopTargetFPS atomic.Int64
|
||||||
)
|
)
|
||||||
|
|
||||||
func persistDisplaySelection(display int) {
|
func persistDisplaySelection(display int) {
|
||||||
@@ -28,21 +30,26 @@ func GetPersistedDisplay() int {
|
|||||||
|
|
||||||
func DesktopStart(ctx context.Context, env *rt.Env) error {
|
func DesktopStart(ctx context.Context, env *rt.Env) error {
|
||||||
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
||||||
interval, fps := streamInterval("OVERLORD_DESKTOP_MAX_FPS", 120)
|
fps := activeDesktopTargetFPS()
|
||||||
if fps < 60 {
|
interval := time.Second / time.Duration(fps)
|
||||||
fps = 60
|
|
||||||
interval = time.Second / time.Duration(fps)
|
|
||||||
}
|
|
||||||
capture.SetH264TargetFPS(fps)
|
capture.SetH264TargetFPS(fps)
|
||||||
log.Printf("desktop: starting stream (target fps %d)", fps)
|
log.Printf("desktop: starting stream (target fps %d)", fps)
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
currentFPS := fps
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Printf("desktop: stopping stream")
|
log.Printf("desktop: stopping stream")
|
||||||
return nil
|
return nil
|
||||||
case <-ticker.C:
|
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 err := capture.Now(ctx, env); err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
log.Printf("desktop: stopping stream")
|
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 {
|
func DesktopSelect(ctx context.Context, env *rt.Env, display int) error {
|
||||||
prev := env.SelectedDisplay
|
prev := env.SelectedDisplay
|
||||||
maxDisplays := capture.MonitorCount()
|
maxDisplays := capture.MonitorCount()
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
|
|||||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||||
MimeType: webrtc.MimeTypeH264,
|
MimeType: webrtc.MimeTypeH264,
|
||||||
ClockRate: 90000,
|
ClockRate: 90000,
|
||||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
SDPFmtpLine: h264SDPFmtpLine,
|
||||||
},
|
},
|
||||||
PayloadType: 102,
|
PayloadType: 102,
|
||||||
}, webrtc.RTPCodecTypeVideo); err != nil {
|
}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func registerVideoWriter(kind Kind, id string, w VideoWriter) {
|
|||||||
entry.video = w
|
entry.video = w
|
||||||
bucket[id] = entry
|
bucket[id] = entry
|
||||||
writersMu.Unlock()
|
writersMu.Unlock()
|
||||||
|
RequestKeyframe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAudioWriter(kind Kind, id string, w AudioWriter) {
|
func registerAudioWriter(kind Kind, id string, w AudioWriter) {
|
||||||
|
|||||||
@@ -37,9 +37,32 @@ func drainRTCP(sender *webrtc.RTPSender) {
|
|||||||
for _, p := range packets {
|
for _, p := range packets {
|
||||||
switch p.(type) {
|
switch p.(type) {
|
||||||
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
|
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
|
||||||
|
handleRTCPKeyframeFeedback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const h264SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034"
|
||||||
|
|
||||||
|
var rtcpKeyframeLogOnce sync.Once
|
||||||
|
|
||||||
|
func handleRTCPKeyframeFeedback() {
|
||||||
|
if !honorRTCPKeyframes() {
|
||||||
|
rtcpKeyframeLogOnce.Do(func() {
|
||||||
|
log.Printf("webrtcpub: ignoring RTCP video keyframe requests by default; set OVERLORD_WEBRTC_RTCP_KEYFRAMES=true to honor them")
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
RequestKeyframe()
|
RequestKeyframe()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func honorRTCPKeyframes() bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_WEBRTC_RTCP_KEYFRAMES"))) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +109,7 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) {
|
|||||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||||
MimeType: webrtc.MimeTypeH264,
|
MimeType: webrtc.MimeTypeH264,
|
||||||
ClockRate: 90000,
|
ClockRate: 90000,
|
||||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
SDPFmtpLine: h264SDPFmtpLine,
|
||||||
},
|
},
|
||||||
PayloadType: 102,
|
PayloadType: 102,
|
||||||
}, webrtc.RTPCodecTypeVideo); err != nil {
|
}, 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
@@ -38,6 +38,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
const cursorCtrl = document.getElementById("cursorCtrl");
|
const cursorCtrl = document.getElementById("cursorCtrl");
|
||||||
const duplicationCtrl = document.getElementById("duplicationCtrl");
|
const duplicationCtrl = document.getElementById("duplicationCtrl");
|
||||||
const resolutionSelect = document.getElementById("resolutionSelect");
|
const resolutionSelect = document.getElementById("resolutionSelect");
|
||||||
|
const targetFpsSelect = document.getElementById("targetFpsSelect");
|
||||||
const smoothingSlider = document.getElementById("smoothingSlider");
|
const smoothingSlider = document.getElementById("smoothingSlider");
|
||||||
const smoothingValue = document.getElementById("smoothingValue");
|
const smoothingValue = document.getElementById("smoothingValue");
|
||||||
const qualitySlider = document.getElementById("qualitySlider");
|
const qualitySlider = document.getElementById("qualitySlider");
|
||||||
@@ -1101,14 +1102,28 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
const codec = q >= 100 ? "raw" : (prefersH264 ? "h264" : "jpeg");
|
const codec = q >= 100 ? "raw" : (prefersH264 ? "h264" : "jpeg");
|
||||||
console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec);
|
console.debug("rd: pushQuality val=", val, "q=", q, "codec=", codec);
|
||||||
setCodecModeLabel(codec, "requested");
|
setCodecModeLabel(codec, "requested");
|
||||||
|
if (codec === "h264") {
|
||||||
|
ensureDuplicationForH264();
|
||||||
|
}
|
||||||
sendCmd("desktop_set_quality", { quality: q, codec });
|
sendCmd("desktop_set_quality", { quality: q, codec });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureDuplicationForH264() {
|
||||||
|
if (!duplicationCtrl || duplicationCtrl.disabled) return;
|
||||||
|
const isWindows = clientOs.includes("windows") || clientOs.includes("win");
|
||||||
|
if (!isWindows || duplicationCtrl.checked) return;
|
||||||
|
duplicationCtrl.checked = true;
|
||||||
|
sendCmd("desktop_set_duplication", { enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (codecH264) {
|
if (codecH264) {
|
||||||
codecH264.addEventListener("change", function () {
|
codecH264.addEventListener("change", function () {
|
||||||
resetH264SessionState();
|
resetH264SessionState();
|
||||||
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
|
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
|
||||||
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
|
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
|
||||||
|
if (prefersH264) {
|
||||||
|
ensureDuplicationForH264();
|
||||||
|
}
|
||||||
if (!prefersH264) {
|
if (!prefersH264) {
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
h264LowFpsStreak = 0;
|
h264LowFpsStreak = 0;
|
||||||
@@ -1127,12 +1142,29 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedTargetFps() {
|
||||||
|
const fps = Number(targetFpsSelect?.value || 120);
|
||||||
|
return Number.isFinite(fps) ? Math.max(1, Math.min(240, Math.floor(fps))) : 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTargetFps() {
|
||||||
|
const fps = selectedTargetFps();
|
||||||
|
console.debug("rd: pushTargetFps fps=", fps);
|
||||||
|
sendCmd("desktop_set_fps", { fps });
|
||||||
|
}
|
||||||
|
|
||||||
if (resolutionSelect) {
|
if (resolutionSelect) {
|
||||||
resolutionSelect.addEventListener("change", function () {
|
resolutionSelect.addEventListener("change", function () {
|
||||||
pushResolution();
|
pushResolution();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetFpsSelect) {
|
||||||
|
targetFpsSelect.addEventListener("change", function () {
|
||||||
|
pushTargetFps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
displaySelect.addEventListener("change", function () {
|
displaySelect.addEventListener("change", function () {
|
||||||
console.debug("rd: select display", displaySelect.value);
|
console.debug("rd: select display", displaySelect.value);
|
||||||
sendCmd("desktop_select_display", {
|
sendCmd("desktop_select_display", {
|
||||||
@@ -1163,6 +1195,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
display: displaySelect?.value ?? "",
|
display: displaySelect?.value ?? "",
|
||||||
quality: qualitySlider?.value ?? "",
|
quality: qualitySlider?.value ?? "",
|
||||||
resolution: resolutionSelect?.value ?? "",
|
resolution: resolutionSelect?.value ?? "",
|
||||||
|
targetFps: targetFpsSelect?.value ?? "",
|
||||||
prefersH264,
|
prefersH264,
|
||||||
duplication: !!duplicationCtrl?.checked,
|
duplication: !!duplicationCtrl?.checked,
|
||||||
clientOs,
|
clientOs,
|
||||||
@@ -1182,6 +1215,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
|||||||
if (qualitySlider) {
|
if (qualitySlider) {
|
||||||
pushQuality(qualitySlider.value);
|
pushQuality(qualitySlider.value);
|
||||||
}
|
}
|
||||||
|
if (prefersH264) {
|
||||||
|
ensureDuplicationForH264();
|
||||||
|
}
|
||||||
|
pushTargetFps();
|
||||||
pushResolution();
|
pushResolution();
|
||||||
desiredStreaming = true;
|
desiredStreaming = true;
|
||||||
lastFrameAt = 0;
|
lastFrameAt = 0;
|
||||||
|
|||||||
@@ -176,6 +176,22 @@
|
|||||||
<option value="relayed">WebRTC Relayed</option>
|
<option value="relayed">WebRTC Relayed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<label class="text-sm text-slate-300 w-20" for="targetFpsSelect">Max FPS</label>
|
||||||
|
<select
|
||||||
|
id="targetFpsSelect"
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||||
|
title="Target capture and encoder frame rate"
|
||||||
|
>
|
||||||
|
<option value="30">30 fps</option>
|
||||||
|
<option value="60">60 fps</option>
|
||||||
|
<option value="90">90 fps</option>
|
||||||
|
<option value="120" selected>120 fps</option>
|
||||||
|
<option value="144">144 fps</option>
|
||||||
|
<option value="165">165 fps</option>
|
||||||
|
<option value="240">240 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-200" title="Prefer H.264 when available; disable to force JPEG">
|
<label class="flex items-center gap-2 text-sm text-slate-200" title="Prefer H.264 when available; disable to force JPEG">
|
||||||
<input type="checkbox" id="codecH264" checked /> H.264
|
<input type="checkbox" id="codecH264" checked /> H.264
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type CommandType =
|
|||||||
| "desktop_select_display"
|
| "desktop_select_display"
|
||||||
| "desktop_enable_mouse"
|
| "desktop_enable_mouse"
|
||||||
| "desktop_enable_keyboard"
|
| "desktop_enable_keyboard"
|
||||||
|
| "desktop_set_fps"
|
||||||
| "darwin_request_permissions"
|
| "darwin_request_permissions"
|
||||||
| "webrtc_publish"
|
| "webrtc_publish"
|
||||||
| "webrtc_stop"
|
| "webrtc_stop"
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export const rdStreamingState = new Map<string, {
|
|||||||
codec: string;
|
codec: string;
|
||||||
duplication: boolean;
|
duplication: boolean;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
|
maxFps: number;
|
||||||
lastFps: number;
|
lastFps: number;
|
||||||
}>();
|
}>();
|
||||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||||
@@ -181,6 +182,15 @@ function pruneRdInputPending(now = Date.now()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultRdStreamingState() {
|
||||||
|
return { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, maxFps: 120, lastFps: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDesktopFps(value: unknown): number {
|
||||||
|
const fps = Math.floor(Number(value) || 120);
|
||||||
|
return Math.max(1, Math.min(240, fps));
|
||||||
|
}
|
||||||
|
|
||||||
function recordRdInput(commandId: string, clientId: string, kind: string) {
|
function recordRdInput(commandId: string, clientId: string, kind: string) {
|
||||||
pruneRdInputPending();
|
pruneRdInputPending();
|
||||||
rdInputPending.set(commandId, { clientId, sentAt: Date.now(), kind });
|
rdInputPending.set(commandId, { clientId, sentAt: Date.now(), kind });
|
||||||
@@ -325,7 +335,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
const state = rdStreamingState.get(clientId) || defaultRdStreamingState();
|
||||||
|
|
||||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
@@ -366,6 +376,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
whepPath: `/api/webrtc/${streamPath}/whep`,
|
whepPath: `/api/webrtc/${streamPath}/whep`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
|
||||||
sendDesktopCommand(target, "desktop_start", {});
|
sendDesktopCommand(target, "desktop_start", {});
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
@@ -489,6 +500,16 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "desktop_set_fps": {
|
||||||
|
const newMaxFps = clampDesktopFps((payload as any).fps);
|
||||||
|
if (state.maxFps !== newMaxFps) {
|
||||||
|
sendDesktopCommand(target, "desktop_set_fps", { fps: newMaxFps });
|
||||||
|
state.maxFps = newMaxFps;
|
||||||
|
rdStreamingState.set(clientId, state);
|
||||||
|
logger.debug(`[rd] set target fps=${newMaxFps}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "desktop_set_duplication": {
|
case "desktop_set_duplication": {
|
||||||
const enabled = !!payload.enabled;
|
const enabled = !!payload.enabled;
|
||||||
if (state.duplication !== enabled) {
|
if (state.duplication !== enabled) {
|
||||||
@@ -648,7 +669,7 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
const clientId = payload.clientId as string;
|
const clientId = payload.clientId as string;
|
||||||
const header = payload.header;
|
const header = payload.header;
|
||||||
const bytes = payload.data as Uint8Array;
|
const bytes = payload.data as Uint8Array;
|
||||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
const state = rdStreamingState.get(clientId) || defaultRdStreamingState();
|
||||||
if (!state.isStreaming) {
|
if (!state.isStreaming) {
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
}
|
}
|
||||||
@@ -667,7 +688,7 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header?: any): boolean {
|
function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header?: any): boolean {
|
||||||
const frameFps = Number(header?.fps) || 0;
|
const frameFps = Number(header?.fps) || 0;
|
||||||
if (frameFps > 0) {
|
if (frameFps > 0) {
|
||||||
const state = rdStreamingState.get(clientId) || { isStreaming: true, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
const state = rdStreamingState.get(clientId) || { ...defaultRdStreamingState(), isStreaming: true };
|
||||||
state.lastFps = frameFps;
|
state.lastFps = frameFps;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ set LOG_LEVEL=debug
|
|||||||
|
|
||||||
start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
|
start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
|
||||||
timeout /t 3 /nobreak >nul
|
timeout /t 3 /nobreak >nul
|
||||||
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set OVERLORD_CAPTURE_METRICS=true && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
||||||
|
|
||||||
echo Done. Terminals stay open (/k) for logs.
|
echo Done. Terminals stay open (/k) for logs.
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
Reference in New Issue
Block a user