Skip to content

feat(macos): implement SCKit-based capture and prefer it to AVFoundation where available - #5511

Open
martona wants to merge 4 commits into
LizardByte:masterfrom
martona:feature/macos-sckit
Open

feat(macos): implement SCKit-based capture and prefer it to AVFoundation where available#5511
martona wants to merge 4 commits into
LizardByte:masterfrom
martona:feature/macos-sckit

Conversation

@martona

@martona martona commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Description

Replaces AVFoundation's AVCaptureScreenInput as the default macOS capture backend with a ScreenCaptureKit-based implementation (macOS 14+; AVFoundation remains as fallback for older systems or SCKit setup failures).

Capture runs through SCScreenshotManager polling paced to the session frame rate, rather than an SCStream.

Two reasons:

  • SCStream's update detection misses or delays small screen changes (e.g. a blinking terminal cursor), producing visible latency for keystroke echo.
  • Mixing SCStream frames with screenshot frames (streaming plus polling as a fallback) causes visible flicker on translucent surfaces such as toolbar materials, because the two paths composite slightly differently.

Polling a single consistent source resolves both, at capture latency measured equal to AVFoundation. Since polling goes through the zero-copy VideoToolbox path by default, there's no CPU cost to it.

Fixes

  • Cursor visibility: SCKit composites the cursor differently, fixing the long-standing AVFoundation bug where a cursor hidden by an application (e.g. while typing in a text field) never reappears in the stream.
  • Host processing latency reporting on macOS (first commit): capture timestamps are derived from sample buffer PTS and survive encoder pipelining via PTS-matched bookkeeping, so Moonlight's ctrl+alt+shift+S host latency stat works on macOS.

Testing

Tested on macOS 26 (Apple silicon, VM, Mac Studio and MBP): cursor reappearance, static-screen behavior, display reconfiguration, latency stat parity, extended interactive sessions at 1080p60/4K60 with hardware (VideoToolbox) and software (x264) encoders.

Screenshot

Issues Fixed or Closed

Closes #3433

Roadmap Issues

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

See our AI usage policy.

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

@martona

martona commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

The remaining QualityGate issue is not mine; it's from 4 months ago. I can certainly fix it but I would rather keep this PR on topic.

@ReenigneArcher

Copy link
Copy Markdown
Member

Agree on the sonar issue. Could you fix the doxygen errors before I review this? https://app.readthedocs.org/projects/sunshinestream/builds/34086280/ You can expand the failed section and ctrl + F for error: to find them.

@martona

martona commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Done. Sorry I missed them last night.

@sethdmoore

Copy link
Copy Markdown
Contributor

This PR compiles against the latest master (as of ~3h ago). I've been testing it out today and it genuinely fixes #3433.
My mouse cursor has not disappeared once yet.

@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

@ReenigneArcher

Copy link
Copy Markdown
Member

Sorry for the delay in getting to this. I don't know much about Apple so the following is according to GPT 5.6.

Main10 capture uses an unsupported ScreenCaptureKit format. At display.mm:590, 10-bit sessions assign x420 to SCStreamConfiguration.pixelFormat. Apple documents only BGRA, l10r, 420v, and 420f as supported formats. Sunshine can reach this path through VideoToolbox’s P010/Main10 capability. Capture errors are then silently discarded at sc_capture.m:270, so the backend neither produces real frames nor falls back to AVFoundation. Keep 10-bit capture on AVFoundation or add a supported ScreenCaptureKit format/conversion path, and propagate permanent screenshot errors. Apple’s pixel-format contract.

Additionally, can you add tests to cover the changes? At minimum, cover delayed packets, dropped PTS values, missing timestamps, and queue eviction.

@Optimiza

Optimiza commented Sep 3, 2026

Copy link
Copy Markdown

Re the Main10 / x420 concern: I tested it empirically rather than against the docs.

Minimal standalone probe calling SCScreenshotManager captureSampleBufferWithFilter:configuration: (the same API this PR polls) with SCStreamConfiguration.pixelFormat set to each of 420v, x420 (kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange), l10r and BGRA, then checking the returned CVPixelBuffer format and sampling plane 0 for content.

macOS 26.5.2 (25F84), Apple M4, display 2048x1152:

Requested NSError Returned format Matches Plane 0 content
420v none 420v yes image
x420 none x420 yes image (biplanar, 2 planes)
l10r none l10r yes image
BGRA none BGRA yes image

So on this system x420 is accepted and delivered as-is by SCScreenshotManager, even though Apple's pixelFormat doc page only lists BGRA/l10r/420v/420f. The header comment (as surfaced by generated bindings) also lists xf44 and RGhA, so the documented list looks like a subset of what the runtime supports.

Caveat: single machine, single OS version. I have not tested macOS 14.x or 15.x, which is where the question is still open given the @available(macOS 14.0, *) gate. I can run the same probe on a macOS 14.8.x (Sonoma, Intel) system tomorrow and will post the result here. Source below if anyone on Sequoia wants to cover 15.x in the meantime; it builds with a single clang line and does not touch Sunshine.

The second part of the review stands regardless: finishScreenshotSampleBuffer: drops the NSError silently and the capture loop in display.mm never returns capture_e::error, so if the format is ever rejected on some configuration there is no log and no fallback to AVFoundation. Logging the error and propagating a persistent failure seems worth doing independently of whether x420 is valid.

sc_pixfmt_probe.m
/*
 * sc_pixfmt_probe.m
 *
 * Minimal standalone probe for LizardByte/Sunshine PR #5511.
 *
 * Question: what does SCScreenshotManager actually do when
 * SCStreamConfiguration.pixelFormat is set to a format that Apple's docs do
 * not list as supported? In particular 'x420'
 * (kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange), which display.mm assigns
 * for 10-bit sessions.
 *
 * Four possible outcomes, each leading to a different conclusion:
 *   1. NSError is returned         -> the review is right, needs a fix.
 *   2. Buffer returned as 'x420'   -> the format works despite the docs.
 *   3. Buffer returned in another  -> silent degradation: worse than an error,
 *      format                         the encoder gets an unexpected format.
 *   4. Buffer returned as 'x420'   -> format accepted but no real data. This is
 *      but all-zero content           what "no frames, no error" would look like.
 *
 * Build:
 *   clang -fobjc-arc -framework Foundation -framework ScreenCaptureKit \
 *         -framework CoreMedia -framework CoreVideo -framework CoreGraphics \
 *         -o sc_pixfmt_probe sc_pixfmt_probe.m
 *
 * Run:
 *   ./sc_pixfmt_probe
 *
 * Requires Screen Recording permission for the terminal app launching it
 * (System Settings > Privacy & Security > Screen Recording).
 */

#import <Foundation/Foundation.h>
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#import <CoreMedia/CoreMedia.h>
#import <CoreVideo/CoreVideo.h>
#import <CoreGraphics/CoreGraphics.h>

static NSString *FourCC(OSType c) {
  if (c == 0) return @"<none>";
  char s[5] = {
    (char) ((c >> 24) & 0xFF),
    (char) ((c >> 16) & 0xFF),
    (char) ((c >> 8) & 0xFF),
    (char) (c & 0xFF),
    0
  };
  for (int i = 0; i < 4; i++) {
    if (s[i] < 32 || s[i] > 126) s[i] = '?';
  }
  return [NSString stringWithFormat:@"'%s' (0x%08X)", s, (unsigned) c];
}

static void probe(SCDisplay *display, OSType fmt, NSString *label) {
  SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:display
                                                   excludingWindows:@[]];

  // Same configuration fields the PR sets in SCCapture.captureVideo.
  SCStreamConfiguration *cfg = [[SCStreamConfiguration alloc] init];
  cfg.width = display.width;
  cfg.height = display.height;
  cfg.pixelFormat = fmt;
  cfg.showsCursor = YES;
  cfg.captureResolution = SCCaptureResolutionBest;
  cfg.preservesAspectRatio = YES;

  printf("\n--- %s: requested %s\n", label.UTF8String, FourCC(fmt).UTF8String);
  printf("    cfg.pixelFormat after assignment: %s\n",
         FourCC(cfg.pixelFormat).UTF8String);

  dispatch_semaphore_t sem = dispatch_semaphore_create(0);

  // Same API the PR polls: SCScreenshotManager, not SCStream.
  [SCScreenshotManager captureSampleBufferWithFilter:filter
                                       configuration:cfg
                                   completionHandler:^(CMSampleBufferRef sb, NSError *err) {
    if (err) {
      printf("    RESULT: NSError -> domain=%s code=%ld  %s\n",
             err.domain.UTF8String,
             (long) err.code,
             err.localizedDescription.UTF8String);
      dispatch_semaphore_signal(sem);
      return;
    }

    if (!sb) {
      printf("    RESULT: no error, but sampleBuffer == NULL\n");
      dispatch_semaphore_signal(sem);
      return;
    }

    printf("    sampleBuffer valid: %s\n",
           CMSampleBufferIsValid(sb) ? "yes" : "no");

    CVImageBufferRef px = CMSampleBufferGetImageBuffer(sb);
    if (!px) {
      printf("    RESULT: sampleBuffer has no imageBuffer\n");
      dispatch_semaphore_signal(sem);
      return;
    }

    OSType got = CVPixelBufferGetPixelFormatType(px);
    printf("    RESULT: buffer delivered with format %s\n",
           FourCC(got).UTF8String);
    printf("    dimensions: %zux%zu  planar=%s planes=%zu\n",
           CVPixelBufferGetWidth(px),
           CVPixelBufferGetHeight(px),
           CVPixelBufferIsPlanar(px) ? "yes" : "no",
           CVPixelBufferIsPlanar(px) ? CVPixelBufferGetPlaneCount(px) : (size_t) 0);
    printf("    matches requested: %s\n",
           got == fmt ? "YES" : "NO  <-- silent degradation");

    // Outcome 4: format accepted but empty content (black frame).
    // Sample plane 0 (Y for biplanar formats, the only plane for packed ones).
    if (CVPixelBufferLockBaseAddress(px, kCVPixelBufferLock_ReadOnly) == kCVReturnSuccess) {
      const uint8_t *base;
      size_t bpr, h;
      if (CVPixelBufferIsPlanar(px)) {
        base = CVPixelBufferGetBaseAddressOfPlane(px, 0);
        bpr = CVPixelBufferGetBytesPerRowOfPlane(px, 0);
        h = CVPixelBufferGetHeightOfPlane(px, 0);
      } else {
        base = CVPixelBufferGetBaseAddress(px);
        bpr = CVPixelBufferGetBytesPerRow(px);
        h = CVPixelBufferGetHeight(px);
      }
      size_t nonzero = 0, total = 0;
      // Sample 1 row out of 64; enough to detect an all-black frame.
      // Note for 'x420': samples are 16-bit LE with the 10-bit value in the high
      // bits, so the low byte is often zero on real images. Expect ~50-70% nonzero.
      for (size_t row = 0; row < h; row += 64) {
        const uint8_t *r = base + row * bpr;
        for (size_t i = 0; i < bpr; i++) {
          total++;
          if (r[i] != 0) nonzero++;
        }
      }
      CVPixelBufferUnlockBaseAddress(px, kCVPixelBufferLock_ReadOnly);
      printf("    plane 0 content: %zu/%zu sampled bytes nonzero -> %s\n",
             nonzero, total,
             nonzero == 0 ? "BLACK FRAME  <-- format accepted but no data" : "has image");
    } else {
      printf("    plane 0 content: could not lock buffer for reading\n");
    }

    dispatch_semaphore_signal(sem);
  }];

  // 10 s headroom: the first call can be slow while permission is being granted.
  if (dispatch_semaphore_wait(sem,
        dispatch_time(DISPATCH_TIME_NOW, 10ull * NSEC_PER_SEC)) != 0) {
    printf("    RESULT: timeout, completion handler not invoked within 10 s\n");
  }
}

int main(void) {
  @autoreleasepool {
    NSOperatingSystemVersion v =
      [[NSProcessInfo processInfo] operatingSystemVersion];
    printf("macOS %ld.%ld.%ld\n",
           (long) v.majorVersion, (long) v.minorVersion, (long) v.patchVersion);

    __block SCShareableContent *content = nil;
    dispatch_semaphore_t sem = dispatch_semaphore_create(0);

    [SCShareableContent getShareableContentWithCompletionHandler:
      ^(SCShareableContent *c, NSError *e) {
        if (e) {
          printf("getShareableContent failed: %s\n",
                 e.localizedDescription.UTF8String);
        } else {
          content = c;
        }
        dispatch_semaphore_signal(sem);
      }];

    dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);

    if (!content || content.displays.count == 0) {
      printf("No displays available. Check Screen Recording permission.\n");
      return 1;
    }

    SCDisplay *display = content.displays.firstObject;
    printf("display id=%u  %zux%zu\n\n",
           (unsigned) display.displayID,
           (size_t) display.width,
           (size_t) display.height);

    // Documented baseline: what Sunshine uses today for 8-bit.
    probe(display, kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, @"420v  8-bit  (documented)");

    // The disputed one: what display.mm assigns for 10-bit sessions.
    probe(display, kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, @"x420 10-bit  (DISPUTED)");

    // Documented 10-bit alternative, in case it is useful as a fallback path.
    probe(display, kCVPixelFormatType_ARGB2101010LEPacked, @"l10r 10-bit  (documented)");

    // Control: should always work.
    probe(display, kCVPixelFormatType_32BGRA, @"BGRA  8-bit  (documented)");

    printf("\nDone.\n");
  }
  return 0;
}
Full output on macOS 26.5.2 / Apple M4
macOS 26.5.2
display id=1  2048x1152


--- 420v  8-bit  (documented): requested '420v' (0x34323076)
    cfg.pixelFormat after assignment: '420v' (0x34323076)
    sampleBuffer valid: yes
    RESULT: buffer delivered with format '420v' (0x34323076)
    dimensions: 2048x1152  planar=yes planes=2
    matches requested: YES
    plane 0 content: 36864/36864 sampled bytes nonzero -> has image

--- x420 10-bit  (DISPUTED): requested 'x420' (0x78343230)
    cfg.pixelFormat after assignment: 'x420' (0x78343230)
    sampleBuffer valid: yes
    RESULT: buffer delivered with format 'x420' (0x78343230)
    dimensions: 2048x1152  planar=yes planes=2
    matches requested: YES
    plane 0 content: 46528/73728 sampled bytes nonzero -> has image

--- l10r 10-bit  (documented): requested 'l10r' (0x6C313072)
    cfg.pixelFormat after assignment: 'l10r' (0x6C313072)
    sampleBuffer valid: yes
    RESULT: buffer delivered with format 'l10r' (0x6C313072)
    dimensions: 2048x1152  planar=no planes=0
    matches requested: YES
    plane 0 content: 147381/147456 sampled bytes nonzero -> has image

--- BGRA  8-bit  (documented): requested 'BGRA' (0x42475241)
    cfg.pixelFormat after assignment: 'BGRA' (0x42475241)
    sampleBuffer valid: yes
    RESULT: buffer delivered with format 'BGRA' (0x42475241)
    dimensions: 2048x1152  planar=no planes=0
    matches requested: YES
    plane 0 content: 147455/147456 sampled bytes nonzero -> has image

Done.

@martona

martona commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Glad to hear the PR is alive. I will address the issues raised - but @Optimiza, if you could hold off reviewing/testing until after I push changes that'd be great. Reason: the current implementation is limited to ~30fps (inherent >20ms latency in SCScreenshotManager, only one in-flight request). I have refactored the PR to use SCStream after all. The original issue (detections dropped) was worked around by using the method OBS uses: cadence set to 0.9*expected_fps. (Credit to Claude for digging this up.) Result: 10ms host-processing latency all-in, smooth 60fps (limited by my displays). It will never reach Windows' NVEnc 4ms number but it's close enough, and I'm very happy with it. I've been using the refactored version for over a week. Let me look at the 10-bit issue in detail and I'll push an update.

@Optimiza

Optimiza commented Sep 4, 2026

Copy link
Copy Markdown

Sounds good, I'll hold off until you push the changes and test everything together then, including the Sonoma/Intel path on the iMac.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS 15: The cursor disappears and does not reappear

4 participants