Skip to content

Commit c3de88a

Browse files
0xFerreiraomarroth
authored andcommitted
Scale capture to the receiver's advertised display size
1 parent 55d3fe2 commit c3de88a

3 files changed

Lines changed: 50 additions & 11 deletions

File tree

cmd/doubletake/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,13 @@ func main() {
324324
if creds := credStore.Lookup(info.DeviceID); creds != nil {
325325
restoreToken = creds.RestoreToken
326326
}
327+
maxW, maxH := info.DisplaySize()
327328
captureCfg := airplay.CaptureConfig{
328329
FPS: *fps,
329330
Bitrate: *bitrate,
330331
HWAccel: *hwaccel,
332+
MaxWidth: maxW,
333+
MaxHeight: maxH,
331334
X11WindowID: xid,
332335
X11WindowName: *x11WindowName,
333336
ShowCursor: !*noCursor,

internal/airplay/capture.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

internal/daemon/daemon.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -858,7 +858,8 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
858858
// Start capture before SetupMirror. Modern receivers start a deadline for
859859
// the first video data during setup, while the Wayland screencast portal may
860860
// wait indefinitely for user selection.
861-
sink, err := d.getOrStartBroadcastLocked(screenCastRestoreToken, deviceID)
861+
capMaxW, capMaxH := info.DisplaySize()
862+
sink, err := d.getOrStartBroadcastLocked(screenCastRestoreToken, deviceID, capMaxW, capMaxH)
862863
if err != nil {
863864
client.Close()
864865
removeStream(fmt.Sprintf("capture failed: %v", err))
@@ -933,7 +934,9 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
933934
// getOrStartBroadcastLocked ensures a shared BroadcastCapture is running and
934935
// returns a new sink registered with it. If no capture is running, it starts one.
935936
// Must NOT be called with d.mu held.
936-
func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string) (*airplay.BroadcastSink, error) {
937+
// maxW/maxH clamp the encoded size for the receiver that starts the capture;
938+
// sinks that join later share it.
939+
func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string, maxW, maxH int) (*airplay.BroadcastSink, error) {
937940
d.mu.Lock()
938941
bc := d.broadcast
939942
d.mu.Unlock()
@@ -949,6 +952,8 @@ func (d *Daemon) getOrStartBroadcastLocked(restoreToken, deviceID string) (*airp
949952
FPS: d.cfg.FPS,
950953
Bitrate: d.cfg.Bitrate,
951954
HWAccel: d.cfg.HWAccel,
955+
MaxWidth: maxW,
956+
MaxHeight: maxH,
952957
ShowCursor: d.cfg.ShowCursor,
953958
RestoreToken: restoreToken,
954959
}

0 commit comments

Comments
 (0)