Skip to content

Commit 48fcdba

Browse files
Harden Wayland video relay
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
1 parent faca56a commit 48fcdba

9 files changed

Lines changed: 154 additions & 24 deletions

File tree

cmd/doubletake/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ func main() {
371371
}
372372
streamCfg.AutomaticHEVCAvailable = capturePreparation.AutomaticHEVCAvailable()
373373
streamCfg.MeasuredVideoLatency = capturePreparation.MeasuredVideoLatency()
374+
streamCfg.MinimumVideoLead = capturePreparation.MinimumVideoLead()
374375

375376
var capture *airplay.ScreenCapture
376377
var broadcast *airplay.BroadcastCapture

internal/airplay/capture.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ func captureMinimumVideoLead(kind capturePreparationKind, measured time.Duration
9797
return measured
9898
}
9999

100+
func waylandRawVideoSize(receiverWidth, receiverHeight int, streamSize [2]int) (int, int) {
101+
if receiverWidth <= 0 || receiverHeight <= 0 {
102+
receiverWidth, receiverHeight = streamSize[0], streamSize[1]
103+
}
104+
receiverWidth, receiverHeight = fitVideoSize(receiverWidth, receiverHeight, 3840, 2160)
105+
return receiverWidth &^ 1, receiverHeight &^ 1
106+
}
107+
100108
// CapturePreparation performs the potentially interactive part of screen
101109
// capture before the receiver session starts. In particular, a Wayland
102110
// preparation completes the screencast portal request and retains its PipeWire
@@ -115,8 +123,9 @@ type CapturePreparation struct {
115123
timestampedOutput bool
116124
automaticHEVCAvail bool
117125
// measuredVideoLatency is the minimum screen lead required by local capture:
118-
// either the 4K HEVC preflight or the isolated Wayland raw relay.
126+
// the 4K HEVC preflight. minimumVideoLead also includes transport overhead.
119127
measuredVideoLatency time.Duration
128+
minimumVideoLead time.Duration
120129

121130
pwNodeID uint32
122131
pwFd *os.File
@@ -217,7 +226,7 @@ func PrepareCapture(ctx context.Context, cfg CaptureConfig) (*CapturePreparation
217226
preparation.pwNodeID = nodeID
218227
preparation.pwFd = pwFd
219228
preparation.dbusConn = dbusConn
220-
preparation.measuredVideoLatency = captureMinimumVideoLead(kind, preparation.measuredVideoLatency)
229+
preparation.minimumVideoLead = captureMinimumVideoLead(kind, preparation.measuredVideoLatency)
221230
return preparation, nil
222231
}
223232

@@ -405,6 +414,14 @@ func (p *CapturePreparation) MeasuredVideoLatency() time.Duration {
405414
return p.measuredVideoLatency
406415
}
407416

417+
// MinimumVideoLead returns the full local capture/transport scheduling floor.
418+
func (p *CapturePreparation) MinimumVideoLead() time.Duration {
419+
if p == nil {
420+
return 0
421+
}
422+
return p.minimumVideoLead
423+
}
424+
408425
// Close releases an unconsumed portal preparation. Once Start has taken
409426
// ownership, ScreenCapture.Stop owns the corresponding resources.
410427
func (p *CapturePreparation) Close() {
@@ -944,10 +961,7 @@ func startPreparedWaylandCapture(ctx context.Context, cfg CaptureConfig, encoder
944961
lowLatencyVideoQueueStage(),
945962
)
946963
}
947-
rawWidth, rawHeight := cfg.MaxWidth&^1, cfg.MaxHeight&^1
948-
if rawWidth <= 0 || rawHeight <= 0 {
949-
rawWidth, rawHeight = streamSize[0]&^1, streamSize[1]&^1
950-
}
964+
rawWidth, rawHeight := waylandRawVideoSize(cfg.MaxWidth, cfg.MaxHeight, streamSize)
951965
if rawWidth <= 0 || rawHeight <= 0 {
952966
cancel()
953967
_ = pwFd.Close()

internal/airplay/capture_broadcast.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type BroadcastCapture struct {
4848
// adjacent PTS values. A leaky upstream queue can legitimately create large
4949
// PTS gaps while only one encoded picture is pending.
5050
frameDuration time.Duration
51+
now func() time.Time
5152
mu sync.Mutex
5253
done chan struct{}
5354
err error // set before done is closed
@@ -57,9 +58,9 @@ type BroadcastCapture struct {
5758
// following sequence, which gives attachment an exact cutover even when a
5859
// source read has completed but has not yet been fanned out.
5960
sequence uint64
60-
// primer is the latest complete parameter-set plus random-access AU. A
61-
// receiver attached after capture starts needs it before live P-frames are
62-
// decodable.
61+
// Timestamped access-unit fan-out caches the latest complete parameter-set
62+
// plus random-access AU. Legacy byte-stream fan-out retains its exact
63+
// next-read cutover and therefore waits for the encoder's next keyframe.
6364
primer VideoAccessUnit
6465

6566
drainTimeout time.Duration
@@ -143,6 +144,7 @@ func NewBroadcastCaptureWithFrameRate(src *ScreenCapture, fps int) *BroadcastCap
143144
src: src,
144145
frames: src != nil && src.frames != nil,
145146
frameDuration: time.Second / time.Duration(fps),
147+
now: time.Now,
146148
done: make(chan struct{}),
147149
drainTimeout: broadcastSinkDrainTimeout,
148150
}
@@ -162,6 +164,9 @@ func (bc *BroadcastCapture) AddSink() *BroadcastSink {
162164
}
163165
s.startSequence = bc.sequence + 1
164166
s.primer = bc.primer
167+
if len(s.primer.AnnexB) > 0 {
168+
s.primer.PTS = bc.now()
169+
}
165170
bc.sinks = append(bc.sinks, s)
166171
bc.mu.Unlock()
167172
return s
@@ -188,6 +193,9 @@ func (bc *BroadcastCapture) AddBackpressuredSink() (*BroadcastSink, error) {
188193
bc.exclusive = true
189194
s.startSequence = bc.sequence + 1
190195
s.primer = bc.primer
196+
if len(s.primer.AnnexB) > 0 {
197+
s.primer.PTS = bc.now()
198+
}
191199
bc.sinks = append(bc.sinks, s)
192200
bc.mu.Unlock()
193201
return s, nil
@@ -277,7 +285,7 @@ func (bc *BroadcastCapture) runFrames() error {
277285
bc.mu.Lock()
278286
if len(frame.AnnexB) <= broadcastSinkQueueBytes && isDecoderPrimer(frame.AnnexB) {
279287
bc.primer = VideoAccessUnit{
280-
AnnexB: append(bc.primer.AnnexB[:0], frame.AnnexB...),
288+
AnnexB: append([]byte(nil), frame.AnnexB...),
281289
PTS: frame.PTS,
282290
}
283291
}
@@ -562,7 +570,7 @@ func (s *BroadcastSink) Read(p []byte) (int, error) {
562570
func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) {
563571
s.mu.Lock()
564572
defer s.mu.Unlock()
565-
for len(s.frameQueue) == 0 && !s.inputClosed && !s.closed {
573+
for len(s.primer.AnnexB) == 0 && len(s.frameQueue) == 0 && !s.inputClosed && !s.closed {
566574
s.cond.Wait()
567575
}
568576
if s.closed {
@@ -571,9 +579,6 @@ func (s *BroadcastSink) ReadVideoAccessUnit() (VideoAccessUnit, error) {
571579
if len(s.primer.AnnexB) > 0 {
572580
primer := s.primer
573581
s.primer = VideoAccessUnit{}
574-
if len(s.frameQueue) > 0 && !s.frameQueue[0].PTS.IsZero() {
575-
primer.PTS = s.frameQueue[0].PTS.Add(-s.frameDuration)
576-
}
577582
if s.inputClosed && len(s.frameQueue) == 0 && len(s.queue) == 0 {
578583
s.closeDoneLocked()
579584
}

internal/airplay/capture_broadcast_test.go

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) {
123123
waitCh: make(chan struct{}),
124124
}
125125
broadcast := NewBroadcastCaptureWithFrameRate(capture, 30)
126+
replayPTS := time.Unix(110, 0)
127+
broadcast.now = func() time.Time { return replayPTS }
126128
runDone := make(chan error, 1)
127129
go func() { runDone <- broadcast.Run() }()
128130

@@ -140,6 +142,34 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) {
140142

141143
sink := broadcast.AddSink()
142144
defer sink.Close()
145+
replayDone := make(chan struct {
146+
frame VideoAccessUnit
147+
err error
148+
}, 1)
149+
go func() {
150+
frame, err := sink.ReadVideoAccessUnit()
151+
replayDone <- struct {
152+
frame VideoAccessUnit
153+
err error
154+
}{frame: frame, err: err}
155+
}()
156+
var replayed VideoAccessUnit
157+
select {
158+
case result := <-replayDone:
159+
if result.err != nil {
160+
t.Fatalf("read replayed decoder primer: %v", result.err)
161+
}
162+
replayed = result.frame
163+
case <-time.After(time.Second):
164+
t.Fatal("cached decoder primer was not available immediately")
165+
}
166+
if !bytes.Equal(replayed.AnnexB, primer.AnnexB) {
167+
t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB)
168+
}
169+
if !replayed.PTS.Equal(replayPTS) {
170+
t.Fatalf("replayed primer PTS = %v, want attachment PTS %v", replayed.PTS, replayPTS)
171+
}
172+
143173
boundary := VideoAccessUnit{
144174
AnnexB: []byte{0, 0, 0, 1, 0x61, 0x40},
145175
PTS: primer.PTS.Add(10 * time.Second),
@@ -153,16 +183,6 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) {
153183
frames <- live
154184
close(frames)
155185

156-
replayed, err := sink.ReadVideoAccessUnit()
157-
if err != nil {
158-
t.Fatalf("read replayed decoder primer: %v", err)
159-
}
160-
if !bytes.Equal(replayed.AnnexB, primer.AnnexB) {
161-
t.Fatalf("first late-sink frame = %x, want cached decoder primer %x", replayed.AnnexB, primer.AnnexB)
162-
}
163-
if wantPTS := live.PTS.Add(-time.Second / 30); !replayed.PTS.Equal(wantPTS) {
164-
t.Fatalf("replayed primer PTS = %v, want one frame before live PTS %v", replayed.PTS, wantPTS)
165-
}
166186
next, err := sink.ReadVideoAccessUnit()
167187
if err != nil {
168188
t.Fatalf("read live frame after decoder primer: %v", err)
@@ -175,6 +195,49 @@ func TestBroadcastCaptureReplaysDecoderPrimerToLateSink(t *testing.T) {
175195
}
176196
}
177197

198+
func TestBroadcastCapturePrimerSnapshotSurvivesRefresh(t *testing.T) {
199+
frames := make(chan VideoAccessUnit)
200+
reads := make(chan struct{}, 4)
201+
capture := &ScreenCapture{
202+
frames: &signaledVideoAccessUnitReader{frames: frames, reads: reads},
203+
waitCh: make(chan struct{}),
204+
}
205+
broadcast := NewBroadcastCapture(capture)
206+
broadcast.now = func() time.Time { return time.Unix(120, 0) }
207+
runDone := make(chan error, 1)
208+
go func() { runDone <- broadcast.Run() }()
209+
210+
first := VideoAccessUnit{AnnexB: []byte{
211+
0, 0, 0, 1, 0x67, 0x42,
212+
0, 0, 0, 1, 0x68, 0xce,
213+
0, 0, 0, 1, 0x65, 0xaa,
214+
}}
215+
second := VideoAccessUnit{AnnexB: []byte{
216+
0, 0, 0, 1, 0x67, 0x64,
217+
0, 0, 0, 1, 0x68, 0xee,
218+
0, 0, 0, 1, 0x65, 0xbb,
219+
}}
220+
<-reads
221+
frames <- first
222+
<-reads
223+
sink := broadcast.AddSink()
224+
defer sink.Close()
225+
frames <- second
226+
<-reads
227+
228+
got, err := sink.ReadVideoAccessUnit()
229+
if err != nil {
230+
t.Fatalf("read primer snapshot: %v", err)
231+
}
232+
if !bytes.Equal(got.AnnexB, first.AnnexB) {
233+
t.Fatalf("primer snapshot = %x, want %x", got.AnnexB, first.AnnexB)
234+
}
235+
close(frames)
236+
if err := <-runDone; !errors.Is(err, io.EOF) {
237+
t.Fatalf("broadcast run = %v, want EOF", err)
238+
}
239+
}
240+
178241
func TestBroadcastSinkBackpressuresWithOnePendingAccessUnit(t *testing.T) {
179242
sink := newBroadcastSinkWithPolicy(nil, true)
180243
base := time.Now()

internal/airplay/capture_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ func TestCaptureMinimumVideoLeadIncludesWaylandRawRelay(t *testing.T) {
151151
}
152152
}
153153

154+
func TestWaylandRawVideoSizeBoundsReceiverCanvas(t *testing.T) {
155+
width, height := waylandRawVideoSize(1<<30, 1<<30, [2]int{2880, 1800})
156+
if width != 2160 || height != 2160 {
157+
t.Fatalf("hostile square receiver canvas = %dx%d, want bounded 2160x2160", width, height)
158+
}
159+
width, height = waylandRawVideoSize(0, 0, [2]int{2881, 1801})
160+
if width != 2880 || height != 1800 {
161+
t.Fatalf("portal fallback canvas = %dx%d, want even 2880x1800", width, height)
162+
}
163+
}
164+
154165
func TestLiveVideoProbeTimeoutTracksConfiguredFrameRate(t *testing.T) {
155166
if got := liveVideoProbeTimeout(30); got != minimumLiveVideoProbeTimeout {
156167
t.Fatalf("30fps live probe timeout = %v, want %v", got, minimumLiveVideoProbeTimeout)

internal/airplay/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,7 @@ type StreamConfig struct {
12031203
VideoCodec VideoCodec // empty/h264, auto, or capability-gated hevc
12041204
AutomaticHEVCAvailable bool // capture preflight found the hardware HEVC-4K path
12051205
MeasuredVideoLatency time.Duration // measured minimum lead for the local HEVC capture path
1206+
MinimumVideoLead time.Duration // known capture/transport lead before codec selection
12061207
NoEncrypt bool // Disable encryption for debugging
12071208
DirectKey bool // Use shk/shiv directly without SHA-512 derivation
12081209
NoAudio bool // Disable audio streaming

internal/airplay/mirror.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
504504
var receiverEventPort int
505505
var attemptedEventPort int
506506
audioControlLPort := audioCtrlConn.LocalAddr().(*net.UDPAddr).Port
507+
if !targetLatencyIsExplicit() {
508+
latencies = latencies.withMinimumVideoLead(cfg.MinimumVideoLead)
509+
}
507510
audioLatencySamples := samplesFor44k1(latencies.audio)
508511
measuredLatencyApplied := false
509512
audioSetupCommitted := false

internal/airplay/receiver_server_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,37 @@ func TestMediaFirstAutoFallsBackBeforeCreatingLatencyMismatch(t *testing.T) {
579579
}
580580
}
581581

582+
func TestMediaFirstRelayLeadPreservesAutomaticHEVC(t *testing.T) {
583+
SetTargetLatency(0)
584+
t.Cleanup(func() { SetTargetLatency(0) })
585+
_, client, ctx := newReceiverServerTestPair(t, ReceiverConfig{
586+
Profile: ReceiverProfileRoku, DisplayWidth: 3840, DisplayHeight: 2160,
587+
})
588+
if err := client.Pair(ctx, ""); err != nil {
589+
t.Fatalf("pair: %v", err)
590+
}
591+
session, err := client.SetupMirrorWithVideoCodecPreparation(ctx, StreamConfig{
592+
VideoCodec: VideoCodecAuto,
593+
AutomaticHEVCAvailable: true,
594+
MinimumVideoLead: 250 * time.Millisecond,
595+
}, func(_, _ int, codec VideoCodec) error {
596+
if codec != VideoCodecHEVC {
597+
return fmt.Errorf("media-first relay codec = %s, want HEVC", codec)
598+
}
599+
return nil
600+
})
601+
if err != nil {
602+
t.Fatalf("setup mirror: %v", err)
603+
}
604+
defer session.Close()
605+
if session.videoCodec != VideoCodecHEVC || session.timestampBias != 250*time.Millisecond {
606+
t.Fatalf("media-first relay session = codec %s lead %v, want HEVC/250ms", session.videoCodec, session.timestampBias)
607+
}
608+
if session.audioStream == nil || session.audioStream.latencySamples != samplesFor44k1(260*time.Millisecond) {
609+
t.Fatalf("media-first relay audio lead = %#v, want %d samples", session.audioStream, samplesFor44k1(260*time.Millisecond))
610+
}
611+
}
612+
582613
func TestMediaFirstCalibratedAutoFallsBackBeforeLiveMeasurement(t *testing.T) {
583614
SetTargetLatency(0)
584615
t.Cleanup(func() { SetTargetLatency(0) })

internal/daemon/daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,7 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ
11351135
streamCfg := d.mirrorStreamConfig()
11361136
streamCfg.AutomaticHEVCAvailable = capturePreparation.AutomaticHEVCAvailable()
11371137
streamCfg.MeasuredVideoLatency = capturePreparation.MeasuredVideoLatency()
1138+
streamCfg.MinimumVideoLead = capturePreparation.MinimumVideoLead()
11381139
var broadcast *airplay.BroadcastCapture
11391140
selectedCaptureKey := videoCaptureKey{maxWidth: -1, maxHeight: -1}
11401141
prepareVideo := func(width, height int, codec airplay.VideoCodec) (airplay.VideoPreparationResult, error) {

0 commit comments

Comments
 (0)