ronaldoussoren / pyobjc

The Python <-> Objective-C Bridge with bindings for macOS frameworks
https://pyobjc.readthedocs.io
546 stars 46 forks source link

Question: trying to grab a screenshot using ScreenCaptureKit #590

Closed Saba-Sabato closed 7 months ago

Saba-Sabato commented 8 months ago

Hello, This is the first time I've used PyObjC. I'm trying to grab a screenshot in Python, using ScreenCaptureKit, adapting the code from this Apple Videos guide. (go to code tab, bottom two code blocks) I've encountered several problems along the way, and I'm here to ask if I've just gone about this completely the wrong way. I went through the PyObjC documentation and the relevant Apple documentation pages: ScreenCaptureKit, captureImageWithFilter, SCContentFilter, SCShareableContent. Here is the code I have so far:

from time import sleep
import objc
from ScreenCaptureKit import (
    SCContentFilter,
    SCScreenshotManager,
    SCStreamConfiguration,
    SCShareableContent,
)
import Quartz.CoreGraphics as CG

def capture_screenshot():
    SCShareableContent.getShareableContentWithCompletionHandler_(
        shareable_content_callback
    )
    sleep(1)  # Without this, the handlers are never executed

def shareable_content_callback(shareable_content, error=None):
    display = shareable_content.displays()[0]
    content_filter = SCContentFilter.alloc().initWithDisplay_excludingWindows_(
        display, []
    )
    configuration = SCStreamConfiguration.alloc().init()

    SCScreenshotManager.captureImageWithFilter_configuration_completionHandler_(
        content_filter, configuration, cg_image_handler
    )

    content_filter.dealloc()
    configuration.dealloc()

def cg_image_handler(cg_image_ref, error=None):
    # Process my CGImage e.g. write it out to file
    pass

def main():
    with objc.autorelease_pool():
        capture_screenshot()

if __name__ == "__main__":
    main()

I can't figure out how to write normal, synchronous code to get the screenshot. The function captureImageWithFilter has a required parameter SCContentFilter, which is initialized with a SCDisplay. To get that I need to call getShareableContentWithCompletionHandler which takes as a parameter a completion handler. That handler will receive the resulting SCDisplay. Only then can I create the parameters required to call captureImageWithFilter which in turn receives yet another handler, and only that final handler will have the CGImage. This feels like callback hell, and I'm sure it's not the way it's supposed to be called. What is the correct way to do this?

Thank you very very much, Saba

ronaldoussoren commented 7 months ago

You need runloop to wait for the completion handler to be called.

Also don't call dealloc methods, that will result in hard crashes. That's similar to why you don't need to call __del__ in Python, although you won't get hard crashes when you do so.

torablien commented 4 months ago

@Saba-Sabato I am also learning pyobjc and trying to grab a screenshot with ScreenCaptureKit. Did you ever get this working / have a working code example you can share?

Thanks! :)

torablien commented 4 months ago

Hi @ronaldoussoren, not sure if this is the right place to ask such a question (feel free to redirect) but could you provide some more context (or a reference) on how to properly wait for the completion handler to be called?

Like the original reporter, I'm trying to write a simple synchronous function that returns (or raises) after screen capture.

I tried some simple implementations with AppHelper.runConsoleEventLoop(), Foundation.CFRunLoopRun(), and Python's threading.Event but couldn't get them to work well/robustly (e.g. terminating program, hanging if there is an error, etc).

Thanks, appreciate all your work on this!

Basic example:

import logging

def capture_screen(save_path: str):
    from ScreenCaptureKit import (
        SCContentFilter,  # type: ignore
        SCScreenshotManager,  # type: ignore
        SCStreamConfiguration,  # type: ignore
        SCShareableContent,  # type: ignore
    )
    from AppKit import NSBitmapImageFileTypePNG, NSBitmapImageRep  # type: ignore
    import Quartz
    import time

    def shareable_content_completion_handler(shareable_content, error):
        try:
            if error:
                logging.error(f"Shareable content completion handler error: {error}")
                return

            if not shareable_content:
                logging.error("No shareable content in completion handler.")
                return

            if not shareable_content.displays():
                logging.error("No displays in shareable content in completion handler.")
                return

            display = shareable_content.displays()[0]

            content_filter = SCContentFilter.alloc().initWithDisplay_excludingApplications_exceptingWindows_(
                display,
                [],
                [],
            )

            configuration = SCStreamConfiguration.alloc().init()
            SCScreenshotManager.captureImageWithFilter_configuration_completionHandler_(
                content_filter, configuration, capture_image_completion_handler
            )
        except Exception as e:
            logging.exception(f"Exception in shareable_content_completion_handler: {e}")

    def capture_image_completion_handler(image, error):
        try:
            if error:
                logging.error(f"Capture image completion handler error: {error}")
                return

            if not image:
                logging.error("No image in completion handler.")
                return

            bitmap_rep = NSBitmapImageRep.alloc().initWithCGImage_(image)

            if not bitmap_rep:
                logging.error("Failed to create bitmap image representation.")
                return

            png_data = bitmap_rep.representationUsingType_properties_(
                NSBitmapImageFileTypePNG, None
            )
            if not png_data:
                logging.error("Failed to convert image to PNG format.")
                return

            png_data.writeToFile_atomically_(save_path, True)
            logging.info(f"Screenshot saved as {save_path}.")
        except Exception as e:
            logging.exception(f"Exception in capture_image_completion_handler: {e}")

    SCShareableContent.getShareableContentWithCompletionHandler_(
        shareable_content_completion_handler
    )

    time.sleep(1)  # TODO: replace with more robust approach

def main():
    logging.basicConfig(level=logging.INFO)
    capture_screen(
        save_path="screenshot.png",
    )

    print("Screenshot captured.")

    with open("screenshot.png", "rb") as f:
        print(f"Screenshot size: {len(f.read())}")

main()
Saba-Sabato commented 3 months ago

@torablien Hey, no, unfortunately I couldn't get ScreenCaptureKit to work. I painstakingly collected information scattered throughout the whole damn web just to get this simple example to work, after which I dropped the subject. I eventually went with another approach, here is some sample code to get you started:

from pathlib import Path

import Foundation
import Quartz
import UniformTypeIdentifiers
import objc

def write_screenshot_to_file(path: Path | str):
    screenshot = get_screenshot()
    url = convert_path_to_ns_url(path)
    result = write_image_to_file(screenshot, url)
    assert result is True, "Failed writing screenshot to file"
    return

def get_screenshot():
    return Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())

def convert_path_to_ns_url(path: Path | str):
    if isinstance(path, str):
        path = Path(path)
    url = Foundation.NSURL.fileURLWithPath_(str(path.absolute()))
    return url

def write_image_to_file(image, path):
    dest = Quartz.CGImageDestinationCreateWithURL(
        path, UniformTypeIdentifiers.UTTypeJPEG.identifier(), 1, None
    )
    assert dest is not None, "Error creating destination, check parameters?"
    Quartz.CGImageDestinationAddImage(dest, image, None)
    result = Quartz.CGImageDestinationFinalize(dest)
    return result

def main(path: Path | str):
    with objc.autorelease_pool():
        write_screenshot_to_file(path)

if __name__ == "__main__":
    main(Path.cwd() / "screenshot.jpeg")

Good luck!