Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qmacscreencapturekit.mm
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
5
6#include <QtCore/qmutex.h>
7
8#include <QtFFmpegMediaPluginImpl/private/qcvimagevideobuffer_p.h>
9#include <QtFFmpegMediaPluginImpl/private/qffmpegdarwinhwframehelpers_p.h>
10#define AVMediaType XAVMediaType
11#include <QtFFmpegMediaPluginImpl/private/qffmpeghwaccel_p.h>
12#include <QtFFmpegMediaPluginImpl/private/qffmpegvideobuffer_p.h>
13extern "C" {
14#include <libavutil/hwcontext_videotoolbox.h>
15}
16#undef AVMediaType
17
18#include <QtMultimedia/private/qavfcamerautility_p.h>
19#include <QtMultimedia/private/qavfhelpers_p.h>
20#include <QtMultimedia/private/qvideoframe_p.h>
21
22#include <CoreMedia/CMTime.h>
23#include <ScreenCaptureKit/ScreenCaptureKit.h>
24
25#include <chrono>
26
27using namespace Qt::Literals::StringLiterals;
28
30 QT_PREPEND_NAMESPACE(QFFmpeg::qLcMacScreenCapture),
31 "qt.multimedia.screencapture.macscreencapturekit");
32
33namespace {
34
35struct QMacScreenCaptureStreamDelegateHelper : public QObject {
36 Q_OBJECT
37signals:
38 void didStopWithError(int64_t streamId, QString);
39};
40
41} // Anonymous namespace
42
43// Events are invoked on system background thread that we don't control.
44@implementation QT_MANGLE_NAMESPACE(QMacScreenCaptureStreamDelegate) {
45@public
46 int64_t m_streamId;
47 QMacScreenCaptureStreamDelegateHelper m_helper;
48}
49
50- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error
51{
52 QT_USE_NAMESPACE
53 using namespace QFFmpeg;
54
55 emit m_helper.didStopWithError(
56 m_streamId,
57 QString::fromNSString(error.localizedDescription));
58}
59
60@end
61
62QT_BEGIN_NAMESPACE
63
64namespace QFFmpeg {
65
66static void handleFrameOutput(
67 QMacScreenCaptureStreamOutput &scStreamOutput,
68 CMSampleBufferRef sampleBufferRef);
69} // namespace QFFmpeg
70
71QT_END_NAMESPACE
72
73// Invoked on background dispatch-queue.
74@implementation QT_MANGLE_NAMESPACE(QMacScreenCaptureStreamOutput) {
75@public
76 // Assigned at construction. We assume it is safe to never reset it, because
77 // we flush the background queue anytime we stop a stream.
78 QT_PREPEND_NAMESPACE(QFFmpeg::QMacScreenCaptureKit) *m_qScreenCaptureKit;
79
80 // Used to track when the underlying window size changed, in pixel-coordinates.
81 std::optional<QSize> m_previousFrameContentRect;
82 std::chrono::microseconds m_startTime;
83 std::optional<std::chrono::microseconds> m_baseTime;
84 std::unique_ptr<QT_PREPEND_NAMESPACE(QFFmpeg::HWAccel)> m_hwAccel;
85}
86
87- (void) stream:(SCStream *) stream
88didOutputSampleBuffer:(CMSampleBufferRef) sampleBufferRef
89 ofType:(SCStreamOutputType) type
90{
91 QT_USE_NAMESPACE
92 using namespace QFFmpeg;
93
94 // SCStreamOutputTypeScreen implies we are receiving video frames
95 // rather than audio samples. It doesn't exclude windows.
96 // Our stream is hardcoded to never report audio samples.
97 Q_ASSERT(type == SCStreamOutputTypeScreen);
98
99 handleFrameOutput(*self, sampleBufferRef);
100}
101
102@end
103
104QT_BEGIN_NAMESPACE
105
106namespace QFFmpeg {
107
108// Reads the SCStreamFrameInfoContentRect for the given CMSampleBufferRef.
109// This usually means the window size at the time a frame was outputted.
110// The resolution outputted is in pixel coordinates.
111// Error message is not user-facing.
112[[nodiscard]] q23::expected<QSize, QString> ReadContentRect(CMSampleBufferRef sampleBuffer)
113{
114 CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false);
115 if (!attachments)
116 return q23::unexpected{ u"CMSampleBuffer has no attachments array"_s };
117
118 CFDictionaryRef attachment = (CFDictionaryRef)CFArrayGetValueAtIndex(attachments, 0);
119
120 NSDictionary *dict = (__bridge NSDictionary *)attachment;
121
122 CGRect contentRect = CGRectZero;
123 NSDictionary *frameInfo = dict[(id)SCStreamFrameInfoContentRect];
124
125 if (frameInfo) {
126 contentRect = CGRectMakeWithDictionaryRepresentation(
127 (__bridge CFDictionaryRef)frameInfo, &contentRect)
128 ? contentRect
129 : CGRectZero;
130 }
131
132 NSNumber *scaleNumber = dict[(id)SCStreamFrameInfoScaleFactor];
133 CGFloat scaleFactor = scaleNumber ? scaleNumber.doubleValue : 1.0;
134
135 NSNumber *contentScaleNumber = dict[(id)SCStreamFrameInfoContentScale];
136 CGFloat contentScale = contentScaleNumber ? contentScaleNumber.doubleValue : 1.0;
137 if (contentScale <= 0.0)
138 contentScale = 1.0;
139
140 return QSize{
141 static_cast<int>(std::round(contentRect.size.width * scaleFactor / contentScale)),
142 static_cast<int>(std::round(contentRect.size.height * scaleFactor / contentScale)), };
143}
144
145// Invoked on background dispatch-queue.
146// Error message is not user-facing.
148 QMacScreenCaptureStreamOutput &scStreamOutput,
149 CMSampleBufferRef sampleBufferRef)
150{
151 CVImageBufferRef imageBufferRef = CMSampleBufferGetImageBuffer(sampleBufferRef);
152 if (!imageBufferRef)
153 return q23::unexpected(u"Cannot get CVImageBufferRef from CMSampleBufferRef"_s);
154 if (CFGetTypeID(imageBufferRef) != CVPixelBufferGetTypeID())
155 return q23::unexpected(u"Grabbed CVImageBufferRef that is not of type CVPixelBuffer"_s);
156
157 auto pixelBuffer = QAVFHelpers::QSharedCVPixelBuffer(
158 imageBufferRef,
159 QAVFHelpers::QSharedCVPixelBuffer::RefMode::NeedsRef);
160
161 // ScreenCaptureKit hands us buffers from its internal pool (see queueDepth),
162 // so copy into a free-standing CVPixelBuffer to decouple the frame's
163 // lifetime from the stream's pool.
164 q23::expected<QAVFHelpers::QSharedCVPixelBuffer, QString> copyResult = deepCopyCvPixelBuffer(
165 pixelBuffer.get());
166 if (!copyResult)
167 return q23::unexpected(u"Failed to copy incoming pixel buffer: "_s + copyResult.error());
168 pixelBuffer = std::move(*copyResult);
169
170 // If the new incoming frames have a different size, update the FFmpeg frames context.
171 QSize incomingFrameSize {
172 static_cast<int>(CVPixelBufferGetWidth(pixelBuffer.get())),
173 static_cast<int>(CVPixelBufferGetHeight(pixelBuffer.get())) };
174 Q_ASSERT(!incomingFrameSize.isEmpty());
175 CvPixelFormat incomingCvPixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer.get());
176 Q_ASSERT(scStreamOutput.m_hwAccel);
177 scStreamOutput.m_hwAccel->updateFramesContext(
178 av_map_videotoolbox_format_to_pixfmt(incomingCvPixelFormat),
179 incomingFrameSize);
180
181 // TODO: We can extract these values specifically with ScreenCaptureKit.
182 std::chrono::microseconds frameTime =
183 QAVFHelpers::CMTimeToMicroseconds(CMSampleBufferGetPresentationTimeStamp(sampleBufferRef));
184 if (!scStreamOutput.m_baseTime) {
185 scStreamOutput.m_baseTime = frameTime;
186 scStreamOutput.m_startTime = frameTime;
187 }
188
189 QVideoFrameFormat format = QAVFHelpers::videoFormatForImageBuffer(pixelBuffer.get());
190 if (!format.isValid())
191 return q23::unexpected(u"Cannot get get video format for image buffer"_s);
192
193 format.setColorSpace(QMacScreenCaptureKit::colorSpace);
194 format.setColorRange(QMacScreenCaptureKit::colorRange);
195 format.setColorTransfer(QMacScreenCaptureKit::colorTransfer);
196
197 Q_ASSERT(scStreamOutput.m_hwAccel);
198 QVideoFrame frame;
199 q23::expected<QVideoFrame, QString> frameResult = QFFmpeg::qVideoFrameFromCvPixelBuffer(
200 *scStreamOutput.m_hwAccel,
201 scStreamOutput.m_startTime - *scStreamOutput.m_baseTime,
202 pixelBuffer,
203 format);
204 if (!frameResult)
205 qCWarning(qLcMacScreenCapture) << frameResult.error();
206 else
207 frame = *frameResult;
208
209 if (!frame.isValid()) {
210 frame = QVideoFramePrivate::createFrame(
211 std::make_unique<QFFmpeg::CVImageVideoBuffer>(std::move(pixelBuffer)),
212 std::move(format));
213 }
214
215 frame.setStartTime((scStreamOutput.m_startTime - *scStreamOutput.m_baseTime).count());
216 frame.setEndTime((frameTime - *scStreamOutput.m_baseTime).count());
217 scStreamOutput.m_startTime = frameTime;
218
219 return frame;
220}
221
222// Main frame handler.
223// Invoked on background dispatch-queue.
225 QFFmpeg::QMacScreenCaptureStreamOutput &streamOutput,
226 CMSampleBufferRef sampleBufferRef)
227{
228 using namespace QFFmpeg;
229
230 Q_ASSERT(streamOutput.m_qScreenCaptureKit);
231
232 // Try to read the updated resolution (content rect) of the window we are
233 // capturing. If we can't read it, then leave stream untouched.
234 // If the window size is different from our current stream configuration,
235 // try to issue a reconfiguration. If there is already an on-going reconfiguration
236 // we skip it and try again next frame.
237 q23::expected<QSize, QString> readContentRectResult = ReadContentRect(sampleBufferRef);
238 if (readContentRectResult) {
239 QSize contentRect = *readContentRectResult;
240
241 // If the content rect is empty, it's usually an indication that the window has
242 // been minimized while capturing it. We keep the stream unchanged so that it
243 // is automatically resumed when the window is restored.
244 if (!contentRect.isEmpty()) {
245 bool newContentRectIsDifferent =
246 streamOutput.m_previousFrameContentRect
247 && *streamOutput.m_previousFrameContentRect != contentRect;
248 if (newContentRectIsDifferent)
249 streamOutput.m_qScreenCaptureKit->updateStream(contentRect);
250
251 streamOutput.m_previousFrameContentRect = contentRect;
252 }
253
254 } else {
255 streamOutput.m_previousFrameContentRect = std::nullopt;
256 qCDebug(qLcMacScreenCapture)
257 << "Unable to read window content rect from CMSampleBUffer: "
258 << readContentRectResult.error();
259 }
260
261 q23::expected<QVideoFrame, QString> videoFrameResult = createQVideoFrame(
262 streamOutput,
263 sampleBufferRef);
264 if (!videoFrameResult) {
265 qCWarning(qLcMacScreenCapture)
266 << "Failed to create qVideoFrame from CMSampleBufferRef: "
267 << videoFrameResult.error();
268 return;
269 }
270
271 emit streamOutput.m_qScreenCaptureKit->newVideoFrameGenerated(
272 streamOutput.m_qScreenCaptureKit->streamId(),
273 std::move(*videoFrameResult));
274}
275
277 QMacScreenCaptureStreamDelegate &streamDelegate,
278 int64_t streamId,
279 const QMacScreenCaptureKit &macScreenCaptureKit)
280{
281 streamDelegate.m_streamId = streamId;
282 QObject::connect(
283 &streamDelegate.m_helper,
284 &QMacScreenCaptureStreamDelegateHelper::didStopWithError,
285 &macScreenCaptureKit,
286 &QMacScreenCaptureKit::streamStoppedWithError);
287}
288
291 QMacScreenCaptureKit &macScreenCaptureKit,
292 uint32_t cvPixelFormat,
293 QSize resolution)
294{
295 auto streamOutput = AVFScopedPointer{ [[QMacScreenCaptureStreamOutput alloc] init] };
296
297 streamOutput.data()->m_qScreenCaptureKit = &macScreenCaptureKit;
298
299 streamOutput.data()->m_hwAccel = HWAccel::create(AV_HWDEVICE_TYPE_VIDEOTOOLBOX);
300 if (!streamOutput.data()->m_hwAccel)
301 return q23::unexpected(
302 u"Unable to create FFmpeg HW context when starting ScreenCaptureKit stream"_s);
303
304 streamOutput.data()->m_hwAccel->createFramesContext(
305 av_map_videotoolbox_format_to_pixfmt(cvPixelFormat),
306 resolution);
307
308 if (!streamOutput.data()->m_hwAccel->hwFramesContextAsBuffer())
309 return q23::unexpected(
310 u"Unable to create FFmpeg HW context when starting ScreenCaptureKit stream"_s);
311
312 return streamOutput;
313}
314
315// The strategy is to flush any remaining jobs on the background thread.
317{
318 if (!m_stream)
319 return;
320
321 // Issue a blocking stop command.
322 dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
323 [m_stream.data() stopCaptureWithCompletionHandler:[semaphore](NSError *error) {
324 if (error) {
325 qCWarning(qLcMacScreenCapture)
326 << "Error while stopping ScreenCaptureKit stream during teardown: "
327 << QString::fromNSString(error.localizedDescription);
328 }
329 dispatch_semaphore_signal(semaphore);
330 }];
331 dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
332 dispatch_release(semaphore);
333
334 // Flush the dispatch_queue. After this we assume it's safe to tear everything down.
335 if (m_dispatchQueue)
336 dispatch_sync(m_dispatchQueue.data(), []{});
337}
338
339// This will commonly fail if we are missing permissions for screen capturing.
340// It will also open the "Grant permissions" system dialog if we are missing
341// permissions.
342//
343// Thread-safe.
344//
345// Error-message is not user-facing
348{
349 // Block functions can only capture copyable types.
350 // Wrap the promise in a shared-ptr.
352
353 // This function call will open the permissions system dialog when applicable.
356 NSError *error)
357 {
358 if (error != nil) {
360 return;
361 }
362
364
368
372
374 }];
375
376 return promise->get_future();
377}
378
379// Note that we are using manual memory management here, because Obj-C block functions
380// do not support capturing move-only types.
387{
389
391 auto future = promise->get_future();
392
395
399
402 *captureKit,
405 if (!streamOutputResult) {
407 return future;
408 }
410
413
418
420 dispatch_queue_create("qt_screencapture", DISPATCH_QUEUE_SERIAL) };
421
422 NSError *addStreamError = nullptr;
427 if (addStreamError != nil) {
428 promise->set_value(q23::unexpected(u"Unable to add stream output to SCStream"_s));
429 return future;
430 }
431
436
437 // Block functions for the completion handler require
438 // that the callable is copyable. This means we can't capture
439 // move-only types. So we temporarily release the unique_ptr here,
440 // and switch to manual memory management and then adopt them
441 // back into AVFScopedPointer inside the callback.
442 // We assume the completion handler is always called, either with success or error.
446 (NSError *error)
447 {
449
450 if (error != nil) {
451 promise->set_value(q23::unexpected{ u"Error when starting screen capturing stream"_s });
452 return;
453 }
454
456 }];
457
458 return future;
459}
460
466{
468
471
473
474 // SCWindow.frame is in screen-points, not pixels. Multiply by
475 // pointPixelScale.
477 streamId,
479 QSize {
480 static_cast<int>(std::round(scWindow.frame.size.width * pointPixelScale)),
481 static_cast<int>(std::round(scWindow.frame.size.height * pointPixelScale)) },
482 frameRate);
483}
484
485AVFScopedPointer<SCStreamConfiguration> QMacScreenCaptureKit::createStreamConfig(
486 QSize resolutionPx,
487 std::optional<qreal> frameRate)
488{
489 // SCStreamConfiguration defines the output format, having zero resolution makes no sense.
490 Q_ASSERT(!resolutionPx.isEmpty());
491
492 // TODO: Possible improvements include specifying pixel format, HDR,
493 // capturing system audio...
494 auto scStreamConfig = AVFScopedPointer{ [[SCStreamConfiguration alloc] init] };
495 scStreamConfig.data().width = resolutionPx.width();
496 scStreamConfig.data().height = resolutionPx.height();
497 // We make a best-effort to always adjust our video output to match the window/screen size.
498 // So we leave scaling off to be pixel-perfect whenever we can.
499 scStreamConfig.data().scalesToFit = false;
500 scStreamConfig.data().queueDepth = QMacScreenCaptureKit::queueDepth;
501 scStreamConfig.data().pixelFormat = QMacScreenCaptureKit::cvPixelFormat;
502 scStreamConfig.data().colorSpaceName = QMacScreenCaptureKit::cgColorSpace();
503 scStreamConfig.data().captureResolution = SCCaptureResolutionBest;
504 if (@available(macOS 15.0, *))
505 scStreamConfig.data().captureDynamicRange = SCCaptureDynamicRangeSDR;
506
507 if (frameRate) {
508 Q_ASSERT(frameRate > 0);
509 scStreamConfig.data().minimumFrameInterval =
510 CMTimeMake(1, static_cast<int32_t>(std::round(*frameRate)));
511 } else {
512 scStreamConfig.data().minimumFrameInterval = kCMTimeZero;
513 }
514
515 return scStreamConfig;
516}
517
518// Issues a stream configuration update, so that the stream will give us video frames
519// of a new resolution.
520void QMacScreenCaptureKit::startStreamReconfigure(
521 SCStream *scStream,
522 QSize resolutionPx,
523 std::optional<qreal> frameRate)
524{
525 Q_ASSERT(scStream);
526
527 AVFScopedPointer<SCStreamConfiguration> scStreamConfig =
528 QMacScreenCaptureKit::createStreamConfig(resolutionPx, frameRate);
529
530 [scStream
531 updateConfiguration:scStreamConfig.data()
532 completionHandler:[](NSError *err) {
533 if (err) {
534 // TODO: Send potential error back to QMacScreenCaptureKit, but only
535 // if the error stops the stream.
536 qCWarning(qLcMacScreenCapture)
537 << "Error when reconfiguring ScreenCaptureKit stream: "
538 << QString::fromNSString(err.description);
539 return;
540 }
541 }];
542}
543
544// Reconfigures the stream with a new output resolution.
545// Does not stop the stream.
546// Input resolution is in pixel-coordinates.
547// Must be called from background dispatch_queue.
548void QMacScreenCaptureKit::updateStream(QSize resolutionPx)
549{
550 Q_ASSERT(m_dispatchQueue);
551 dispatch_assert_queue(m_dispatchQueue.data());
552
553 startStreamReconfigure(m_stream.data(), resolutionPx, m_frameRate);
554}
555
556} // namespace QFFmpeg
557
558QT_END_NAMESPACE
559
560#include "moc_qmacscreencapturekit_p.cpp"
561#include "qmacscreencapturekit.moc"
void updateStream(QSize resolutionPx)
Q_DECLARE_LOGGING_CATEGORY(qLcMacScreenCapture)
static void handleFrameOutput(QMacScreenCaptureStreamOutput &scStreamOutput, CMSampleBufferRef sampleBufferRef)
QT_MANGLE_NAMESPACE(QMacScreenCaptureStreamDelegate) QMacScreenCaptureStreamDelegate
static q23::expected< QVideoFrame, QString > createQVideoFrame(QMacScreenCaptureStreamOutput &scStreamOutput, CMSampleBufferRef sampleBufferRef)
static q23::expected< AVFScopedPointer< QMacScreenCaptureStreamOutput >, QString > createStreamOutput(QMacScreenCaptureKit &macScreenCaptureKit, uint32_t cvPixelFormat, QSize resolution)
q23::expected< QSize, QString > ReadContentRect(CMSampleBufferRef sampleBuffer)
static void configureStreamDelegate(QMacScreenCaptureStreamDelegate &streamDelegate, int64_t streamId, const QMacScreenCaptureKit &macScreenCaptureKit)
Q_LOGGING_CATEGORY_IMPL(QT_PREPEND_NAMESPACE(QFFmpeg::qLcMacScreenCapture), "qt.multimedia.screencapture.macscreencapturekit")