@@ -21,6 +21,11 @@ type CaptureConfig struct {
2121 Bitrate int // Video bitrate in kbps (0 = auto)
2222 HWAccel string // "auto", "nvenc", "vaapi", "openh264", or "none" (x264)
2323
24+ // MaxWidth/MaxHeight clamp the encoded frame to the size the receiver
25+ // advertised in /info. Zero leaves the capture at its native size.
26+ MaxWidth int
27+ MaxHeight int
28+
2429 X11WindowID uint64
2530 X11WindowName string
2631
@@ -189,34 +194,60 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
189194 // - Wayland compositors may stop publishing an undamaged screen. A forced-live
190195 // GStreamer compositor repeats its input pad's latest frame at a regular
191196 // rate, because AirPlay requires continuous video even for a static image.
192- // The stream is encoded at the portal's native resolution; we do not rescale
193- // because the captured surface size is whatever the compositor hands us. The
194- // actual encoded dimensions are read back from the H.264 SPS downstream.
197+ // The encoded dimensions are capped to the receiver's advertised display size
198+ // when available. The actual result is read back from the H.264 SPS downstream.
195199 const pwFdNum = 3
196200 source := gstStage {
197201 "pipewiresrc" , fmt .Sprintf ("fd=%d" , pwFdNum ), fmt .Sprintf ("path=%d" , nodeID ), "do-timestamp=true" ,
198202 fmt .Sprintf ("keepalive-time=%d" , frameIntervalMillis (fps )),
199203 }
200204
205+ // Receivers advertise the largest frame their decoder accepts; an oversized
206+ // stream makes some of them stop consuming after the first IDR. add-borders
207+ // preserves the display aspect ratio when the source and receiver differ.
208+ scaleToReceiver := cfg .MaxWidth > 0 && cfg .MaxHeight > 0
209+ hasCompositor := streamSize [0 ] > 0 && streamSize [1 ] > 0 && hasGstElement ("compositor" )
210+
201211 var beforeConvert []gstStage
202212 if hasGstElement ("vapostproc" ) {
203- beforeConvert = append (beforeConvert , gstStage {"vapostproc" })
213+ stage := gstStage {"vapostproc" }
214+ if scaleToReceiver && ! hasCompositor {
215+ stage = append (stage , "add-borders=true" )
216+ }
217+ beforeConvert = append (beforeConvert , stage )
204218 } else {
205219 log .Printf ("[CAPTURE] vapostproc unavailable, using software conversion" )
220+ if scaleToReceiver && ! hasCompositor {
221+ beforeConvert = append (beforeConvert , gstStage {"videoscale" , "add-borders=true" })
222+ }
206223 }
207- afterFormat := []gstStage {lowLatencyVideoQueueStage ()}
208- if streamSize [0 ] > 0 && streamSize [1 ] > 0 && hasGstElement ("compositor" ) {
224+
225+ var afterFormat []gstStage
226+ if hasCompositor {
209227 beforeConvert = append (beforeConvert ,
210228 gstStage {"compositor" , "force-live=true" , "ignore-inactive-pads=true" , "background=black" },
211229 gstStage {fmt .Sprintf ("video/x-raw,width=%d,height=%d,framerate=%d/1" , streamSize [0 ], streamSize [1 ], fps )},
212230 )
231+ // The compositor fixes its output to the portal size, so scale downstream.
232+ if scaleToReceiver {
233+ afterFormat = append (afterFormat , gstStage {"videoscale" , "add-borders=true" })
234+ }
213235 } else {
214236 log .Printf ("[CAPTURE] idle-frame compositor unavailable; using portal frame timing" )
215- afterFormat = []gstStage {
216- {"videorate" , "drop-only=true" , "skip-to-first=true" },
237+ }
238+ if scaleToReceiver {
239+ afterFormat = append (afterFormat , gstStage {fmt .Sprintf (
240+ "video/x-raw,width=%d,height=%d,pixel-aspect-ratio=1/1" , cfg .MaxWidth , cfg .MaxHeight ,
241+ )})
242+ }
243+ if hasCompositor {
244+ afterFormat = append (afterFormat , lowLatencyVideoQueueStage ())
245+ } else {
246+ afterFormat = append (afterFormat ,
247+ gstStage {"videorate" , "drop-only=true" , "skip-to-first=true" },
217248 frameRateStage (fps ),
218249 lowLatencyVideoQueueStage (),
219- }
250+ )
220251 }
221252 gstArgs := buildGstVideoPipeline (source , beforeConvert , afterFormat , encoderParts )
222253
0 commit comments