Skip to content

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

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

martona wants to merge 9 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.

@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.

@martona

martona commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Capture is now SCStream, not SCScreenshotManager polling. With the refactor and necessary fixes to my initial SCStream approach, it delivers reliably: pitch-perfect typing, a solid 60fps under motion, and ~10ms host processing latency all-in on my hardware. The screenshot engine is gone entirely, which also removes the 30-40fps ceiling its serial ~25ms-per-request cost imposed.

Re: Main10/x420 — @Optimiza's probe showed x420 working, but it exercised SCScreenshotManager, and the branch now uses SCStream, whose format validation may differ; and either way the docs don't promise it. So instead of relying on it: 10-bit sessions attempt x420, and if the runtime rejects it at stream start, capture retries once with 420v and logs a warning. VideoToolbox accepts 8-bit input into a Main10 session, so the negotiated stream survives with 8-bit-sourced content. A reasonable degradation given macOS HDR capture isn't a thing here yet. I didn't move 10-bit to AVFoundation because that would reintroduce the disappearing-cursor bug (#3433) and AVFoundation's higher capture latency for a fringe path.

Re: silent error handling — sc_capture is now Objective-C++ and all its logging goes through BOOST_LOG into sunshine.log. Stream start failures propagate out of the capture backend (capture_e::error -> normal reinit machinery), and a mid-stream stream death wakes the capture loop into reinit instead of hanging. The finishScreenshotSampleBuffer: error swallowing called out in the review no longer exists along with the rest of the screenshot path.

Tests: added coverage for the frame-timestamp bookkeeping: delayed packets, dropped PTS values, missing timestamps, and queue eviction, plus in-order round-trip, unknown pts, empty queue, and double-consume. The helpers were re-targeted at a plain queue type to make them testable without an encoder session (which collided with the software-encode-device relocation on master; same idea, resolved in the rebase).

@Optimiza, good to test now, and the Sonoma/Intel pass would be especially valuable: that's the configuration where both the SCStream format handling and the x420 fallback are most likely to behave differently from my Apple Silicon machines.

With capture fixed, the dominant latency term on the default config is now the ffmpeg VideoToolbox encoder itself (~120ms on my test systems, vs ~10-15ms with encoder = software). I have a native VideoToolbox encoder path queued as a follow-up PR that brings hardware encode to ~10ms. Not pushed yet because I'm out of slots.

@martona

martona commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

The remaining Sonar finding is not mine & from a year ago; I can fix but would prefer to keep the PR on topic. @ReenigneArcher your call, I'm happy either way.

Comment thread third-party/inputtino Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're no longer using inputtino, looks like you may have accidentally commited this when rebasing?

@Optimiza

Optimiza commented Sep 9, 2026

Copy link
Copy Markdown

Test report: macOS 26.5 / Apple M4

I built this branch and ran it against a real Moonlight client. Summary: the SCStream capture itself works, but frames delivered through the VideoToolbox zero-copy path carry stale content, so the remote image freezes or shows a blank (green) frame while the pipeline keeps encoding and sending at close to the target frame rate.

Forcing encoder = software makes the picture correct from the same capture, so the fault is isolated to the zero-copy handoff rather than to ScreenCaptureKit.

I also hit three build problems that stop the .app from running out of the build tree at all. They are unrelated to the capture issue, and unambiguous, so they come first.

Environment

Host Apple M4, macOS 26.5.2
Toolchain Apple clang 21.0.0, CMake 4.4.3, Ninja 1.13.2, Qt 6.11.2
Branch feature/macos-sckit @ 25994e9b
Configure cmake -B build -G Ninja -S . -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF
Client Moonlight, H.264, 1080p60

Unit tests pass: 580 run, 579 passed, 1 skipped (Windows-only), 0 failed. The eight FrameTimestampQueueTest cases added by this branch are green.


1. Cursor after typing (#3433) looks fixed

In the one session where the picture was usable (see section 5), the cursor came back on its own at any position on screen after typing, with no need for the usual workaround of dragging it down to the Dock. That is the behaviour #3433 asks for, and I could not reproduce the old symptom at all in that session.

Worth noting how this was observed: it was through encoder = software, because that is the only configuration in which the remote image updates at all on this machine. The cursor is composited by SCStream itself (config.showsCursor = YES, sc_capture.mm:265), which is identical on both encoder paths, so the observation is about the capture layer and should carry over. I have not been able to confirm it on the default VideoToolbox path, because the capture issue below hides it.

2. The .app bundle is built with an incomplete web UI (race)

web-ui is declared as add_custom_target(web-ui ALL ...) (cmake/targets/common.cmake:90) and writes into ${CMAKE_BINARY_DIR}/assets. The bundle is populated by a POST_BUILD step on the sunshine target that copies that same directory (cmake/targets/macos.cmake:18-22).

Nothing orders the two: web-ui is never added as a dependency of sunshine, so Ninja runs them in parallel. On my first build the copy ran 13 seconds before the web UI finished writing its output:

Sunshine.app/Contents/Resources/assets/web   16:09:03
build/assets/web/index.html                  16:09:16

The bundle ended up with 1 of the 82 web files, and no index.html. The configuration UI answered over HTTPS but served a blank page.

This is a race, not a deterministic failure: a later rebuild happened to order the web UI step before the link step and produced a complete bundle. So it will reproduce intermittently, and probably not at all with -j1.

Suggested fix: add_dependencies(sunshine web-ui), or make the POST_BUILD copy depend on the web UI output.

3. apps.json is never copied into the .app bundle

cmake/packaging/macos.cmake copies src_assets/macos/assets/ into the build tree only inside the SUNSHINE_BUILD_HOMEBREW branch (line 3 guards the file(COPY ...) at line 8). The .app branch has no equivalent, so Contents/Resources/assets/apps.json does not exist.

config.cpp:1750 then fails at startup and the process dies after ~2 seconds:

Failed to apply config: filesystem error: in copy_file: No such file or directory
["<...>/state/apps.json"] ["../Resources/assets/apps.json"]

Workaround used here: place apps.json manually and point file_apps at it.

4. The built .app only runs from Contents/MacOS

SUNSHINE_ASSETS_DIR_DEF is the relative path ../Resources/assets (cmake/compile_definitions/macos.cmake:10). It is resolved against the current working directory, not against the bundle, and it backs WEB_DIR, the app images and the apps.json copy above. Launching the binary by absolute path from anywhere else fails as in section 3.


5. Main issue: stale frames on the VideoToolbox zero-copy path

What is seen

The attached recording shows the client during the failure: the picture is stuck and cursor movement produces short-lived blocks of garbage.

The remote image freezes on one frame, or shows a uniform green frame, while moving the mouse produces brief localized artefacts. Audio and input are unaffected.

Measured on a screen recording of the client, restricted to the remote video area: 88% of frames are identical to the previous one, in 22 freeze runs, the longest lasting 4.23 s. Timeline, one character per 1/3 s, . frozen, # changing:

............#.#...#########.###..#......#...

What the server side shows

The pipeline is not starved. In every failing session the server keeps sending at 39-54 fps average, with per-second peaks of 60-65, for as long as the client is connected. Whatever is wrong, frames are being converted, encoded and sent at close to the requested rate while the picture on the client never changes.

Two things I checked that turned out not to discriminate, listed so nobody else spends time on them:

  • Encoder did not produce IDR frame when requested! (video.cpp:1864) shows up in only 2 of the 5 failing sessions (6 and 4 occurrences), and not in the other three. It is absent from the working session, but with a single working session that is weak evidence. It is not a reliable marker for this bug.
  • The share of frames logged as Dupe does not separate the two cases either: the working session has the highest rate of all (8.0%), above the failing ones (0.7%, 2.3%, 3.5%, 9.0%).

Isolation

Four sessions, changing one thing at a time:

Encoder SCStream scaling Result
h264_videotoolbox (zero-copy) yes, 1920x1080 on a 2048x1152 display green / frozen
h264_videotoolbox (zero-copy) no, 1920x1080 on a 1920x1080 display frozen, brief artefacts on cursor movement
h264_videotoolbox (zero-copy) no, with capture_buffer_size lowered to 6 green with artefacts
libx264 (encoder = software) no, native capture correct image, cursor correct

Two hypotheses were tested and ruled out:

  • Scaling. Setting the host display to exactly the requested resolution, so SCStream captures native, does not fix it.
  • Surface exhaustion. capture_buffer_size (video.cpp:1577) is 12 while SCStreamConfiguration.queueDepth is capped at 8, so the pipeline can retain more surfaces than the stream owns. Lowering it to 6 and rebuilding does not fix it either. (The mismatch still looks worth addressing on its own, see below.)

Note that the encoder choice and the capture geometry are coupled and cannot be varied independently through configuration: resolution_fn is called from exactly one place in the tree, nv12_zero_device::set_frame (nv12_zero_device.cpp:62), so selecting VideoToolbox is what makes SCStream scale, while the software path leaves capture at the display's native size. Equalizing the two resolutions on the host is what allowed a clean comparison.

Where this points

nv12_zero_device::convert hands the CVPixelBufferRef straight to data[3] for AV_PIX_FMT_VIDEOTOOLBOX, so img->data is unused on this path and the encoder reads the ScreenCaptureKit IOSurface directly. av_img_t.h and nv12_zero_device.cpp are unchanged from master, so what changed is the origin and lifetime of the surfaces now being handed to VideoToolbox. One thing worth a look: av_pixel_buf_t holds CVPixelBufferLockBaseAddress(..., kCVPixelBufferLock_ReadOnly) for the whole lifetime of the pooled image, which was harmless for AVFoundation buffers but pins a CPU mapping on a surface WindowServer owns and recycles.

I did not manage to pin the exact mechanism, so this last paragraph is a lead, not a finding.


6. Separate: the capture loop never signals "no new frame"

push_captured_image_cb_t takes (img, bool frame_captured) (common.h:690), where false means "no new frame, reuse the previous one". The SCKit loop only ever calls it with true (display.mm:440), and when copyLatestSampleBuffer returns null it does continue without pushing anything (display.mm:417-423). The result of the bounded wait is explicitly discarded (display.mm:409-410) even though the poll interval is 1/60 s (display.mm:85).

video.cpp:2438 sets a minimum FPS target (half the client framerate) that exists precisely to keep frames flowing while the screen is static. With no false push, that mechanism never fires on this backend, so a completely idle desktop produces no frames at all.

This is not what caused the issue in section 5 (frames flow at full rate there), but it should bite on a static screen.

7. Minor

config.queueDepth = 8 (sc_capture.mm:275) writes the value that Apple documents as both the default and the maximum: "If not set the default value is 8 frames ... and should not exceed 8 frames." The comment above it states the invariant that the value must exceed the number of buffers the pipeline holds at once, which cannot be satisfied while capture_buffer_size is 12. Whichever way this is resolved, the line as written sets nothing.


Evidence

Session log, both key sessions

Failing, default encoder, capture size equal to the display size so SCStream is not scaling:

[05:17:20.790]: Info: Found H.264 encoder: h264_videotoolbox [videotoolbox]
[05:17:21.298]: Info: CLIENT CONNECTED
[05:17:21.374]: Info: Minimum FPS target set to ~30fps (33.3333ms)
[05:17:21.417]: Info: SCCapture stream configured: 1920x1080 @ 60 fps
[05:17:27.106]: Error: Encoder did not produce IDR frame when requested!
[05:17:27.496]: Error: Encoder did not produce IDR frame when requested!
[05:17:39.194]: Error: Encoder did not produce IDR frame when requested!

Working, software encoder, same capture backend:

[05:09:51.037]: Info: Found H.264 encoder: libx264 [software]
[05:09:51.493]: Info: CLIENT CONNECTED
[05:09:51.588]: Info: Minimum FPS target set to ~30fps (33.3333ms)
[05:09:51.639]: Info: SCCapture stream configured: 2048x1152 @ 60 fps
All sessions, with the numbers quoted above

Host display was 2048x1152 until 05:16 and 1920x1080 from then on, so the SCStream is scaling column is the capture size compared against the display size at that moment.

Time Encoder Capture Scaling Result Sent Avg fps Dupe IDR errors
04:54 videotoolbox 1920x1080 yes frozen n/a n/a n/a 6
05:03 videotoolbox 1920x1080 yes green 628 48.3 3.5% 0
05:09 libx264 2048x1152 no correct 10275 55.2 8.0% 0
05:12 videotoolbox 1920x1080 yes green 431 53.9 0.7% 0
05:17 videotoolbox 1920x1080 no frozen 1239 53.9 2.3% 4
05:21 videotoolbox, capture_buffer_size = 6 1920x1080 no green 591 39.4 9.0% 0

n/a for the 04:54 row is not zero: that session predates raising the log level, and Sent Frame is logged at verbose. It is an absence of logging, not an absence of frames.

How the numbers were derived

Server-side frame rate and duplicate share, per session log (min_log_level = verbose):

grep -h "Sent Frame" "$LOG" | awk '
  { split($2, t, ":"); c[t[1]":"t[2]":"int(t[3])]++; n++; if (/Dupe/) d++ }
  END { for (x in c) { s += c[x]; k++ }
        printf "avg_fps=%.1f seconds=%d sent=%d dupe=%.1f%%\n", s/k, k, n, 100*d/n }'

Freeze measurement on the client recording, restricted to the remote video area so that client-side UI does not count as movement. First the per-frame mean difference, then the runs of frames that did not change:

ffmpeg -v error -i recording.mp4 \
  -vf "crop=1084:608:204:174,tblend=all_mode=difference,signalstats,metadata=print:file=diff.txt" \
  -f null -
grep YAVG diff.txt | awk -F= '{ printf "%.3f\n", $2 }' > yavg.txt
awk '$1 < 0.5 { c++ } END { printf "%d of %d frames unchanged (%.0f%%)\n", c, NR, 100*c/NR }' yavg.txt

The recording is 30 fps, so a run of 30 unchanged frames is one second of frozen picture.

recording-PR5511-cropped.mp4

@ReenigneArcher ReenigneArcher mentioned this pull request Sep 16, 2026
2 tasks
@martona
martona force-pushed the feature/macos-sckit branch from 25994e9 to f051973 Compare September 22, 2026 11:00
@martona

martona commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Apologies for the late response, life got in the way. Thanks for the thorough pass @Optimiza, this was genuinely useful. Point by point:

1. Cursor (#3433): good to hear it holds. That's the capture layer, so it applies to both encoder paths.

2/3/4. Web UI race, missing apps.json, CWD-relative assets dir: agreed on all three, but they're pre-existing master behavior when running the raw build tree rather than the packaged bundle (the PR's only cmake changes are adding the ScreenCaptureKit framework and the two sc_capture sources). Worth an issue of their own; I'd rather not fold them into this PR.

5. Frozen/green picture on the VT H.264 path: really great catch, but it's a pre-existing issue, not an SCKit one. It
reproduces against current master with AVFoundation capture and ffmpeg's h264_videotoolbox, so the zero-copy handoff isn't the cause. I hadn't hit it because I'd only tested hardware HEVC (fine) and software H.264 (also fine). Something about the VT H.264 output on current macOS trips a Moonlight bug: I dumped the exact elementary stream as it was being sent and, while Moonlight rendered it garbled, the same file plays back correctly in mpv and VLC. Switching Moonlight to software decoding also clears it up. Not proposing that as a workaround, but it does localize the fault to the client's hardware decode path. I also verified in a standalone harness that VT H.264 encodes SCStream surfaces correctly and that the output decodes cleanly with both libavcodec software and the VideoToolbox hwaccel. Could you confirm the master repro on your M4 and, if you can, the software-decode result on your client? I'll open a separate issue for it.

6. No false push on timeout: on this branch it's a no-op in practice. Both macOS encoders carry PARALLEL_ENCODING, so capture runs the async path, where the callback only uses frame_captured to decide whether to raise the image and the minimum-fps floor is enforced by the encoder loop's own timed wait, which repeats the last frame. That's also why an idle desktop does produce frames here. Still, the documented contract says to report timeouts, so the loop now pushes false on the poll interval like x11grab and kmsgrab do.

7. queueDepth comment: 8 is the API cap, so the value can't enforce the invariant the comment claimed. Kept the explicit 8 (the default has varied across macOS releases) and rewrote the comment to say what's actually true.

@ReenigneArcher: rebased onto current master, and the stray inputtino gitlink is gone. It came from a stale submodule checkout that survived the libvirtualhid change and got swept into a commit on my side.

@sonarqubecloud

Copy link
Copy Markdown

@Optimiza

Copy link
Copy Markdown

Re point 5, confirming the master repro: yes, it reproduces here too, and I have production logs from before this thread that already show it.

Setup: a separate Homebrew install of current stable (2026.914.233613, unrelated to this PR, AVFoundation capture, no SCKit) runs alongside my PR test bench on the same Mac mini M4. The client that originally needed the workaround is a remote Intel Mac (2015, Core i5 1.6GHz dual-core, Intel HD 6000, macOS Sonoma via OpenCore Legacy Patcher), connecting over WAN.

Log evidence, h264_videotoolbox in use, client connects normally, then I force software 82 seconds later:

[2026-09-17 19:05:39.538]: Info: Found H.264 encoder: h264_videotoolbox [videotoolbox]
[2026-09-17 19:05:54.225]: Info: CLIENT CONNECTED
[2026-09-17 19:05:54.238]: Info: Minimum FPS target set to ~30fps (33.3333ms)
[2026-09-17 19:07:13.424]: Info: config: 'encoder' = software
[2026-09-17 19:07:13.566]: Info: Found H.264 encoder: libx264 [software]
[2026-09-17 19:07:27.333]: Info: CLIENT CONNECTED
[2026-09-17 19:07:27.346]: Info: Minimum FPS target set to ~30fps (33.3333ms)

Today I reverted sunshine.conf back to hardware encoding to retest directly:

[2026-09-22 16:03:57.649]: Info: Found H.264 encoder: h264_videotoolbox [videotoolbox]
[2026-09-22 16:04:05.030]: Info: CLIENT CONNECTED
[2026-09-22 16:04:05.044]: Info: Minimum FPS target set to ~30fps (33.3333ms)

A mobile Moonlight client connected against this session and had no freeze with h264_videotoolbox. I don't have access to the Intel Mac that originally needed the workaround until Thursday/Friday this week (2026-09-24/25); I'll test the client-side software-decode setting on that machine then, since it's the one that actually reproduces the freeze, and report back.

Let me know if there's anything else worth testing while I have that window.

@martona

martona commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for this. I guess what'd be helpful is to run the master branch (and possibly this PR) and test it as widely as possible. My Moonlight clients are all Nvidia and they all exhibit the HW decode issue as long as the source is VT h.264. M3 and M5 Macs. You said the client-side issue was also present on Intel integrated, but not on mobile; odd, and encouraging.

@martona

martona commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on item 5: root cause found, and it predates this PR. VideoToolbox's low-latency H.264 sessions emit long-term reference pictures, which some client hardware decoders mishandle; #5200 exposed it by dropping the all-IDR behaviour. Full analysis, the fix (EnableLTR=false on the VT session, verified end to end), and a proposal for the ffmpeg encoder are in #5469. It's a host-side encoder setting, not a capture issue, and it needs an ffmpeg patch in build-deps rather than anything in this PR, so I'd like to keep it out of scope here.

@Optimiza

Copy link
Copy Markdown

Follow-up on the frozen H.264 picture reported here: tested on a Windows NVIDIA client with controls, results in #5469. The native encoder with EnableLTR = false on macos-latency clears it; the release and the same bench on the ffmpeg encoder freeze.

@Optimiza

Copy link
Copy Markdown

The build-tree defects from my report are now filed separately as #5787, as suggested. One correction there: section 4 of my report (the .app only running from Contents/MacOS) was wrong, since main.cpp anchors the working directory at startup; what I saw was the missing apps.json.

This branch has not been deployed

No deployments
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