@@ -24,6 +24,12 @@ type CaptureConfig struct {
2424 HWAccel string // "auto", "vaapi", "none"
2525}
2626
27+ const (
28+ defaultVideoBitrateKbps = 4500
29+ minVideoBitrateKbps = 1800
30+ maxVideoBitrateKbps = 12000
31+ )
32+
2733// ScreenCapture manages screen capture via GStreamer.
2834type ScreenCapture struct {
2935 cmd * exec.Cmd // gst-launch-1.0 process
@@ -80,7 +86,7 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
8086 "pipewiresrc" , fmt .Sprintf ("fd=%d" , pwFdNum ), fmt .Sprintf ("path=%d" , nodeID ), "do-timestamp=true" ,
8187 "!" , "videoconvert" ,
8288 "!" , "videoscale" ,
83- "!" , "videorate" ,
89+ "!" , "videorate" , "drop-only=true" , "skip-to-first=true" ,
8490 "!" , fmt .Sprintf ("video/x-raw,width=%d,height=%d,framerate=%d/1" , cfg .Width , cfg .Height , fps ),
8591 "!" , "queue" , "max-size-buffers=1" , "max-size-bytes=0" , "max-size-time=0" , "leaky=downstream" ,
8692 }
@@ -90,7 +96,7 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
9096 gstArgs = append (gstArgs , "!" )
9197 gstArgs = append (gstArgs , encoderParts .parts ... )
9298 gstArgs = append (gstArgs ,
93- "!" , "h264parse" , "config-interval=-1 " ,
99+ "!" , "h264parse" , "config-interval=0 " ,
94100 "!" , "video/x-h264,stream-format=byte-stream,alignment=au" ,
95101 "!" , "fdsink" , "fd=1" , "sync=false" , "async=false" ,
96102 )
@@ -179,7 +185,7 @@ func startX11Capture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, er
179185 gstArgs = append (gstArgs , "!" )
180186 gstArgs = append (gstArgs , encoder .parts ... )
181187 gstArgs = append (gstArgs ,
182- "!" , "h264parse" , "config-interval=-1 " ,
188+ "!" , "h264parse" , "config-interval=0 " ,
183189 "!" , "video/x-h264,stream-format=byte-stream,alignment=au" ,
184190 "!" , "fdsink" , "fd=1" , "sync=false" , "async=false" ,
185191 )
@@ -354,7 +360,7 @@ func parseXrandrGeometry(line string) (xOffset, width int, ok bool) {
354360// encoderResult holds the detected encoder pipeline parts and whether it needs
355361// a vulkanupload step before the encoder.
356362type encoderResult struct {
357- parts []string
363+ parts []string
358364 needsVulkan bool // encoder needs vulkanupload ! before it
359365}
360366
@@ -366,10 +372,8 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
366372 if fps <= 0 {
367373 fps = 30
368374 }
369- bitrate := cfg .Bitrate
370- if bitrate <= 0 {
371- bitrate = 10000
372- }
375+ bitrate := captureBitrateKbps (cfg )
376+ keyframeInterval := keyframeIntervalFrames (fps )
373377 hwaccel := cfg .HWAccel
374378
375379 // Try Vulkan H.264 (NVENC via Vulkan API) — lowest latency, no CPU usage
@@ -380,7 +384,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
380384 parts : []string {
381385 "vulkanh264enc" ,
382386 "b-frames=0" ,
383- fmt .Sprintf ("idr-period=%d" , fps * 2 ),
387+ fmt .Sprintf ("idr-period=%d" , keyframeInterval ),
384388 "rate-control=cbr" ,
385389 fmt .Sprintf ("bitrate=%d" , bitrate ),
386390 },
@@ -396,7 +400,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
396400 return encoderResult {parts : []string {
397401 "nvh264enc" ,
398402 fmt .Sprintf ("bitrate=%d" , bitrate ),
399- fmt .Sprintf ("gop-size=%d" , fps * 2 ),
403+ fmt .Sprintf ("gop-size=%d" , keyframeInterval ),
400404 "bframes=0" ,
401405 "rc-mode=cbr" ,
402406 "preset=low-latency-hq" ,
@@ -415,7 +419,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
415419 return encoderResult {parts : []string {
416420 "vah264enc" ,
417421 fmt .Sprintf ("bitrate=%d" , bitrate ),
418- fmt .Sprintf ("key-int-max=%d" , fps * 2 ),
422+ fmt .Sprintf ("key-int-max=%d" , keyframeInterval ),
419423 "b-frames=0" ,
420424 "rate-control=cbr" ,
421425 }}
@@ -427,16 +431,23 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
427431
428432 // Software fallback: x264enc
429433 log .Printf ("[CAPTURE] using software encoding (x264enc)" )
434+ vbvBuf := vbvBufferKbit (bitrate , fps )
435+ // Use VBR (pass=0) so the encoder can undershoot on simple scenes, saving
436+ // headroom for complex frames. vbv-buf-capacity + vbv-maxrate cap bursts.
437+ maxrate := bitrate + bitrate / 4 // allow 25% overshoot on peaks
430438 return encoderResult {parts : []string {
431439 "x264enc" ,
432440 "tune=zerolatency" ,
433- "speed-preset=ultrafast " ,
441+ "speed-preset=superfast " ,
434442 fmt .Sprintf ("bitrate=%d" , bitrate ),
435- fmt .Sprintf ("key-int-max=%d" , fps * 2 ),
443+ fmt .Sprintf ("vbv-buf-capacity=%d" , vbvBuf ),
444+ fmt .Sprintf ("key-int-max=%d" , keyframeInterval ),
445+ "pass=0" ,
446+ "option-string=" + fmt .Sprintf ("vbv-maxrate=%d" , maxrate ),
436447 "bframes=0" ,
448+ "sliced-threads=true" ,
437449 "byte-stream=true" ,
438450 "aud=false" ,
439- "vbv-buf-capacity=50" ,
440451 }}
441452}
442453
@@ -452,10 +463,8 @@ func StartTestCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, e
452463 fps = 30
453464 }
454465
455- bitrate := cfg .Bitrate
456- if bitrate <= 0 {
457- bitrate = 10000
458- }
466+ bitrate := captureBitrateKbps (cfg )
467+ keyframeInterval := keyframeIntervalFrames (fps )
459468
460469 // GStreamer pipeline: videotestsrc → timeoverlay → x264enc High profile → Annex-B byte stream → stdout
461470 // pattern=18 = ball (bouncing ball with motion); timeoverlay adds a frame counter
@@ -467,9 +476,11 @@ func StartTestCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, e
467476 "!" , "videoconvert" ,
468477 "!" , "x264enc" ,
469478 "tune=zerolatency" ,
479+ "speed-preset=superfast" ,
470480 fmt .Sprintf ("bitrate=%d" , bitrate ),
471- fmt .Sprintf ("key-int-max=%d" , fps * 2 ),
481+ fmt .Sprintf ("key-int-max=%d" , keyframeInterval ),
472482 "threads=1" ,
483+ "sliced-threads=true" ,
473484 "byte-stream=true" ,
474485 "!" , "video/x-h264,profile=high,stream-format=byte-stream" ,
475486 "!" , "fdsink" , "fd=1" ,
@@ -525,6 +536,66 @@ func logStderr(prefix string, r io.Reader) {
525536 }
526537}
527538
539+ func captureBitrateKbps (cfg CaptureConfig ) int {
540+ if cfg .Bitrate > 0 {
541+ return cfg .Bitrate
542+ }
543+
544+ fps := cfg .FPS
545+ if fps <= 0 {
546+ fps = 30
547+ }
548+ width := cfg .Width
549+ if width <= 0 {
550+ width = 1920
551+ }
552+ height := cfg .Height
553+ if height <= 0 {
554+ height = 1080
555+ }
556+
557+ bitrate := recommendedBitrateKbps (width , height , fps )
558+ log .Printf ("[CAPTURE] auto bitrate selected: %d kbps for %dx%d@%dfps" , bitrate , width , height , fps )
559+ return bitrate
560+ }
561+
562+ func recommendedBitrateKbps (width , height , fps int ) int {
563+ if width <= 0 || height <= 0 || fps <= 0 {
564+ return defaultVideoBitrateKbps
565+ }
566+
567+ bitrate := (width * height * fps + 7500 ) / 15000
568+ if bitrate < minVideoBitrateKbps {
569+ return minVideoBitrateKbps
570+ }
571+ if bitrate > maxVideoBitrateKbps {
572+ return maxVideoBitrateKbps
573+ }
574+ return bitrate
575+ }
576+
577+ func keyframeIntervalFrames (fps int ) int {
578+ if fps <= 0 {
579+ fps = 30
580+ }
581+ return fps * 4
582+ }
583+
584+ // vbvBufferKbit returns the x264 VBV buffer size in kbit for the given bitrate
585+ // and FPS. Sized at ~2 frames of data — enough headroom for the encoder to
586+ // handle scene changes without severe quality oscillation, but tight enough to
587+ // prevent large burst spikes that choke Wi-Fi links.
588+ func vbvBufferKbit (bitrateKbps , fps int ) int {
589+ if bitrateKbps <= 0 || fps <= 0 {
590+ return 300
591+ }
592+ vbv := bitrateKbps * 2 / fps
593+ if vbv < 200 {
594+ return 200
595+ }
596+ return vbv
597+ }
598+
528599// requestScreencast uses the xdg-desktop-portal D-Bus API to request screen capture
529600// permission and returns a PipeWire node ID, an fd for the portal's PipeWire remote,
530601// and the D-Bus connection (which must stay open to keep the screencast session alive).
0 commit comments