|
5 | 5 | #import <CoreMedia/CoreMedia.h> |
6 | 6 | #import <ScreenCaptureKit/ScreenCaptureKit.h> |
7 | 7 |
|
| 8 | +#include <mach/mach_time.h> |
| 9 | + |
8 | 10 | #include <atomic> |
9 | 11 | #include <chrono> |
10 | 12 | #include <memory> |
@@ -52,6 +54,7 @@ - (void)stream:(SCStream*)stream |
52 | 54 | double scaleFactor = 0.0; |
53 | 55 | double contentScale = 0.0; |
54 | 56 | CGRect contentRect = CGRectZero; |
| 57 | + std::optional<std::uint64_t> displayTicks; |
55 | 58 | CFArrayRef attachments = |
56 | 59 | CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false); |
57 | 60 | if (attachments != nullptr && CFArrayGetCount(attachments) > 0) { |
@@ -88,19 +91,45 @@ - (void)stream:(SCStream*)stream |
88 | 91 | if (contentRectDict != nullptr) { |
89 | 92 | CGRectMakeWithDictionaryRepresentation(contentRectDict, &contentRect); |
90 | 93 | } |
| 94 | + const auto* displayTimeNumber = |
| 95 | + static_cast<CFNumberRef>(CFDictionaryGetValue( |
| 96 | + attachment, (__bridge CFStringRef)SCStreamFrameInfoDisplayTime |
| 97 | + )); |
| 98 | + if (displayTimeNumber != nullptr) { |
| 99 | + std::int64_t ticks = 0; |
| 100 | + CFNumberGetValue(displayTimeNumber, kCFNumberSInt64Type, &ticks); |
| 101 | + if (ticks > 0) displayTicks = static_cast<std::uint64_t>(ticks); |
| 102 | + } |
91 | 103 | } |
92 | 104 |
|
93 | 105 | CVPixelBufferRef pixels = CMSampleBufferGetImageBuffer(sampleBuffer); |
94 | 106 | if (pixels == nullptr) return; |
95 | 107 |
|
96 | 108 | // Stamp the frame with when it was presented, not when this callback ran - |
97 | 109 | // a sample delayed inside ScreenCaptureKit must not look fresh to |
98 | | - // freshness-gated consumers. Presentation time and the host clock share the |
99 | | - // same time base, so the age subtraction is exact. |
| 110 | + // freshness-gated consumers. SCStreamFrameInfoDisplayTime is the display |
| 111 | + // time in mach absolute ticks, the same clock mach_absolute_time() reads, |
| 112 | + // so the age subtraction stays within one clock domain. When the attachment |
| 113 | + // is absent, fall back to the sample's presentation timestamp measured |
| 114 | + // against the CoreMedia host clock it is produced on. |
100 | 115 | auto capturedAt = std::chrono::steady_clock::now(); |
101 | | - const CMTime presentation = |
102 | | - CMSampleBufferGetPresentationTimeStamp(sampleBuffer); |
103 | | - if (CMTIME_IS_VALID(presentation)) { |
| 116 | + if (displayTicks) { |
| 117 | + static const double nanosPerTick = [] { |
| 118 | + mach_timebase_info_data_t timebase{}; |
| 119 | + mach_timebase_info(&timebase); |
| 120 | + return static_cast<double>(timebase.numer) / |
| 121 | + static_cast<double>(timebase.denom); |
| 122 | + }(); |
| 123 | + const double ageNanos = |
| 124 | + (static_cast<double>(mach_absolute_time()) - |
| 125 | + static_cast<double>(*displayTicks)) * |
| 126 | + nanosPerTick; |
| 127 | + capturedAt -= std::chrono::duration_cast<std::chrono::steady_clock::duration>( |
| 128 | + std::chrono::duration<double, std::nano>(ageNanos) |
| 129 | + ); |
| 130 | + } else if (const CMTime presentation = |
| 131 | + CMSampleBufferGetPresentationTimeStamp(sampleBuffer); |
| 132 | + CMTIME_IS_VALID(presentation)) { |
104 | 133 | const CMTime hostNow = CMClockGetTime(CMClockGetHostTimeClock()); |
105 | 134 | const double ageSeconds = |
106 | 135 | CMTimeGetSeconds(CMTimeSubtract(hostNow, presentation)); |
@@ -135,21 +164,33 @@ - (void)stream:(SCStream*)stream |
135 | 164 | )); |
136 | 165 | } |
137 | 166 |
|
138 | | -- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error { |
139 | | - const auto chan = self->channel; |
| 167 | +// Shared death path for didStopWithError and streamDidBecomeInactive. The |
| 168 | +// active flag flips synchronously on the delegate thread, so a death that |
| 169 | +// happens before a concurrent stop() wins the exchange and its report cannot |
| 170 | +// be suppressed; delivery still hops onto the sample handler queue so it is |
| 171 | +// serialized with frame delivery and covered by stop()'s drain. |
| 172 | +static void reportStreamDeath( |
| 173 | + const std::shared_ptr<robot::mac::StreamChannel>& chan, NSString* message |
| 174 | +) { |
140 | 175 | if (chan == nullptr || chan->queue == nil) return; |
141 | | - // Delegate callbacks arrive on ScreenCaptureKit's own queue; hop onto the |
142 | | - // sample handler queue so error delivery is serialized with frame delivery |
143 | | - // and covered by stop()'s drain. Only report deaths the caller did not |
144 | | - // initiate - stop() deactivates the channel first. |
145 | | - NSString* message = error.localizedDescription; |
| 176 | + if (!chan->active.exchange(false)) return; // stop() already initiated |
| 177 | + if (chan->onError == nullptr) return; |
146 | 178 | dispatch_async(chan->queue, ^{ |
147 | | - if (chan->active.exchange(false) && chan->onError != nullptr) { |
148 | | - chan->onError(robot::Error::captureFailed(message.UTF8String)); |
149 | | - } |
| 179 | + chan->onError(robot::Error::captureFailed(message.UTF8String)); |
150 | 180 | }); |
151 | 181 | } |
152 | 182 |
|
| 183 | +- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error { |
| 184 | + reportStreamDeath(self->channel, error.localizedDescription); |
| 185 | +} |
| 186 | + |
| 187 | +- (void)streamDidBecomeInactive:(SCStream*)stream { |
| 188 | + reportStreamDeath( |
| 189 | + self->channel, |
| 190 | + @"stream became inactive (the captured window closed or capture ended)" |
| 191 | + ); |
| 192 | +} |
| 193 | + |
153 | 194 | @end |
154 | 195 |
|
155 | 196 | namespace robot::mac { |
@@ -379,6 +420,16 @@ void stop() override { |
379 | 420 | const std::uint32_t windowId, const WindowStreamOptions& options, |
380 | 421 | VideoFrameCallback onFrame, StreamErrorCallback onError |
381 | 422 | ) { |
| 423 | + // SCContentFilter.pointPixelScale and .contentRect - the metadata the whole |
| 424 | + // coordinate contract rests on - exist since macOS 14. |
| 425 | + if (@available(macOS 14.0, *)) { |
| 426 | + // Streaming is available; continue below. |
| 427 | + } else { |
| 428 | + return std::unexpected( |
| 429 | + Error::unsupported("window streaming requires macOS 14 or newer") |
| 430 | + ); |
| 431 | + } |
| 432 | + |
382 | 433 | auto contentResult = fetchShareableContent(); |
383 | 434 | if (!contentResult) return std::unexpected(contentResult.error()); |
384 | 435 |
|
|
0 commit comments