Skip to content

Commit c3c728f

Browse files
domenkozarclaude
andcommitted
capture(wayland): fix black screen and pts timing on gst-launch
Keep the gst-launch CLI for the Wayland portal path, but fold in two fixes: - vapostproc ! video/x-raw,format=I420 imports the portal's DMA-BUF via VA-API (plain videoconvert fails to negotiate DMA-BUF on many drivers) and forces 4:2:0 — RGB screens otherwise make x264enc emit "High 4:4:4 Predictive", which most receiver decoders reject (black screen). - videorate drop-only=true skip-to-first=true ! framerate caps re-stamps buffers onto a regular fps timeline; the portal can deliver pts=0, which confuses encoder/muxer timing. Verified end-to-end: mirrors a Wayland desktop to a non-Apple AirPlay 2 receiver (Hisense), yuv420p, no crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f0c233 commit c3c728f

1 file changed

Lines changed: 22 additions & 14 deletions

File tree

internal/airplay/capture.go

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,25 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
8787

8888
encoderParts := detectGstEncoder(cfg)
8989

90-
// Single GStreamer pipeline: capture from PipeWire portal and encode to H.264.
91-
// Keep the pipeline simple — pipewiresrc ! videoconvert handles DMA-BUF to
92-
// system memory conversion automatically. The stream is encoded at the portal's
93-
// native resolution; we do not rescale to a configured size because the captured
94-
// surface size is whatever the compositor hands us (Wayland surfaces are not
95-
// pixel-perfect to any requested size). The actual encoded dimensions are read
96-
// back from the H.264 SPS downstream.
97-
// videorate drop-only=true passes frames through without duplicating during
98-
// idle periods (avoids wasting bandwidth on static screens). skip-to-first
99-
// avoids buffering before the first frame.
90+
// Single GStreamer pipeline: capture from the PipeWire portal and encode to
91+
// H.264.
92+
// - vapostproc imports the portal's DMA-BUF via VA-API (plain videoconvert
93+
// fails to negotiate DMA-BUF on many drivers, giving a black screen).
94+
// - format=I420 forces 4:2:0 — RGB screens otherwise make x264enc emit
95+
// "High 4:4:4 Predictive", which most receiver decoders reject (black).
96+
// - videorate re-stamps buffers onto a regular fps timeline: the portal can
97+
// deliver pts=0, which confuses encoder/muxer timing. drop-only=true never
98+
// duplicates frames during idle periods (no wasted bandwidth on a static
99+
// screen); skip-to-first avoids buffering before the first frame.
100+
// The stream is encoded at the portal's native resolution; we do not rescale
101+
// because the captured surface size is whatever the compositor hands us. The
102+
// actual encoded dimensions are read back from the H.264 SPS downstream.
100103
const pwFdNum = 3
101104
gstArgs := []string{
102105
"--quiet",
103106
"pipewiresrc", fmt.Sprintf("fd=%d", pwFdNum), fmt.Sprintf("path=%d", nodeID), "do-timestamp=true",
107+
"!", "vapostproc",
108+
"!", "video/x-raw,format=I420",
104109
"!", "videoconvert",
105110
"!", "videorate", "drop-only=true", "skip-to-first=true",
106111
"!", fmt.Sprintf("video/x-raw,framerate=%d/1", fps),
@@ -125,13 +130,15 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
125130
if err != nil {
126131
cancel()
127132
pwFd.Close()
133+
dbusConn.Close()
128134
return nil, fmt.Errorf("gst stdout pipe: %w", err)
129135
}
130136
stderr, _ := cmd.StderrPipe()
131137

132138
if err := cmd.Start(); err != nil {
133139
cancel()
134140
pwFd.Close()
141+
dbusConn.Close()
135142
return nil, fmt.Errorf("start gst-launch: %w", err)
136143
}
137144
pwFd.Close() // child inherited it
@@ -258,30 +265,31 @@ func (sc *ScreenCapture) Read(buf []byte) (int, error) {
258265
}
259266

260267
func (sc *ScreenCapture) Stop() {
261-
if sc.stopped || sc.cmd == nil {
268+
if sc.stopped {
262269
return
263270
}
264271
sc.stopped = true
265272
if sc.cancel != nil {
266273
sc.cancel()
267274
}
268275

269-
// Close stdout to unblock any pending Read() call
276+
// Close stdout to unblock any pending Read() call.
270277
if sc.stdout != nil {
271278
sc.stdout.Close()
272279
}
273280

274281
if sc.dbusConn != nil {
275282
sc.dbusConn.Close()
276283
}
277-
if sc.cmd.Process != nil {
284+
285+
if sc.cmd != nil && sc.cmd.Process != nil {
278286
_ = sc.cmd.Process.Signal(os.Interrupt)
279287
}
280288

281289
select {
282290
case <-sc.waitCh:
283291
case <-time.After(2 * time.Second):
284-
if sc.cmd.Process != nil {
292+
if sc.cmd != nil && sc.cmd.Process != nil {
285293
_ = sc.cmd.Process.Kill()
286294
}
287295
<-sc.waitCh

0 commit comments

Comments
 (0)