From 04adec54f984b32c84a1e24c4307417706ffa514 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 16:12:06 -0500 Subject: [PATCH 01/28] WIP on par with sync get_frame --- src/AutoSplit.py | 51 +++++----- src/capture_method/BitBltCaptureMethod.py | 10 +- src/capture_method/CaptureMethodBase.py | 84 +++++++++++++--- .../DesktopDuplicationCaptureMethod.py | 5 +- .../VideoCaptureDeviceCaptureMethod.py | 95 +++++-------------- .../WindowsGraphicsCaptureMethod.py | 37 +++++--- src/menu_bar.py | 1 + src/region_selection.py | 2 +- src/utils.py | 2 + 9 files changed, 160 insertions(+), 127 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 7f7c9e13..2790d8a5 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -42,6 +42,7 @@ BGRA_CHANNEL_COUNT, FROZEN, ONE_SECOND, + QTIMER_FPS_LIMIT, auto_split_directory, decimal, flatten, @@ -49,7 +50,6 @@ open_file, ) -CHECK_FPS_ITERATIONS = 10 DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = 2 # Needed when compiled, along with the custom hook-requests PyInstaller hook @@ -252,7 +252,7 @@ def __update_live_image_details(self, capture: MatLike | None, called_from_timer if called_from_timer: if self.is_running or self.start_image: return - capture = self.capture_method.get_frame() + capture = self.capture_method.last_captured_image # Update title from target window or Capture Device name capture_region_window_label = ( @@ -390,7 +390,7 @@ def __take_screenshot(self): screenshot_index += 1 # Grab screenshot of capture region - capture = self.capture_method.get_frame() + capture = self.capture_method.last_captured_image if not is_valid_image(capture): error_messages.region() return @@ -413,23 +413,25 @@ def __check_fps(self): if self.reset_image: images.append(self.reset_image) - # run X iterations of screenshotting capture region + comparison + displaying. - t0 = time() - last_capture: MatLike | None = None - for image in images: - count = 0 - while count < CHECK_FPS_ITERATIONS: - new_capture = self.__get_capture_for_comparison() - _ = image.compare_with_capture(self, new_capture) - # TODO: If an old image is always returned, this becomes an infinite loop - if new_capture is not last_capture: - count += 1 - last_capture = new_capture - - # calculate FPS - t1 = time() - fps = int((CHECK_FPS_ITERATIONS * len(images)) / (t1 - t0)) - self.fps_value_label.setText(str(fps)) + i = 0 + + def new_frames_counter(capture: MatLike | None): + nonlocal i + if self.capture_method.last_captured_image is None: + # Capture target dropped or inexistant + return + for image in images: + _ = image.compare_with_capture(self, capture) + i += 1 + + self.capture_method.set_fps_limit(QTIMER_FPS_LIMIT) + self.capture_method.subscribe_to_new_frame(new_frames_counter) + QTest.qWait(ONE_SECOND) + fps = str(int(i / len(images)) - 1) + self.capture_method.unsubscribe_from_new_frame(new_frames_counter) + self.capture_method.set_fps_limit(self.settings_dict["fps_limit"]) + + self.fps_value_label.setText(fps) def __is_current_split_out_of_range(self): return ( @@ -791,7 +793,7 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): def __get_capture_for_comparison(self): """Grab capture region and resize for comparison.""" - capture = self.capture_method.get_frame() + capture = self.capture_method.last_captured_image # This most likely means we lost capture # (ie the captured window was closed, crashed, lost capture device, etc.) @@ -804,9 +806,10 @@ def __get_capture_for_comparison(self): if self.settings_dict["capture_method"] == CaptureMethodEnum.BITBLT: message += "\n(captured window may be incompatible with BitBlt)" self.live_image.setText(message) - recovered = self.capture_method.recover_window(self.settings_dict["captured_window_title"]) - if recovered: - capture = self.capture_method.get_frame() + _recovered = self.capture_method.recover_window(self.settings_dict["captured_window_title"]) + # TODO: Gotta wait next loop now + # if recovered: + # capture = self.capture_method.last_captured_image self.__update_live_image_details(capture) return capture diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 079a010a..94dc667f 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -9,7 +9,7 @@ from typing_extensions import override from win32 import win32gui -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import BGRA_CHANNEL_COUNT, get_window_bounds, is_valid_hwnd, try_delete_dc # This is an undocumented nFlag value for PrintWindow @@ -24,7 +24,7 @@ def is_blank(image: MatLike): return not image.any() -class BitBltCaptureMethod(CaptureMethodBase): +class BitBltCaptureMethod(ThreadedLoopCaptureMethod): name = "BitBlt" short_description = "fastest, least compatible" description = ( @@ -36,13 +36,13 @@ class BitBltCaptureMethod(CaptureMethodBase): _render_full_content = False @override - def get_frame(self) -> MatLike | None: + def _read_action(self) -> MatLike | None: selection = self._autosplit_ref.settings_dict["capture_region"] hwnd = self._autosplit_ref.hwnd image: MatLike | None = None - if not self.check_selected_region_exists(): - return None + # if not self.check_selected_region_exists(): + # return None # If the window closes while it's being manipulated, it could cause a crash try: diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index d03765f7..bb4177fb 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -1,8 +1,13 @@ +from abc import ABCMeta, abstractmethod +from collections.abc import Callable from typing import TYPE_CHECKING from cv2.typing import MatLike +from PySide6 import QtCore +from typing_extensions import override -from utils import is_valid_hwnd +import error_messages +from utils import ONE_SECOND, QTIMER_FPS_LIMIT, is_valid_hwnd if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -13,10 +18,11 @@ class CaptureMethodBase: short_description = "" description = "" + last_captured_image: MatLike | None = None _autosplit_ref: "AutoSplit" + _subscriptions: list[Callable[[MatLike | None], object]] = [] # FIXME: # noqa: RUF012 def __init__(self, autosplit: "AutoSplit"): - # Some capture methods don't need an initialization process self._autosplit_ref = autosplit def reinitialize(self): @@ -24,20 +30,76 @@ def reinitialize(self): self.__init__(self._autosplit_ref) # type: ignore[misc] def close(self): - # Some capture methods don't need an initialization process + # Some capture methods don't need any cleanup pass - def get_frame(self) -> MatLike | None: # noqa: PLR6301 - """ - Captures an image of the region for a window matching the given - parameters of the bounding box. - - @return: The image of the region in the window in BGRA format - """ - return None + def set_fps_limit(self, fps: int): + # CaptureMethodBase doesn't actually record. This is implemented by child classes + pass def recover_window(self, captured_window_title: str) -> bool: # noqa: PLR6301 return False def check_selected_region_exists(self) -> bool: return is_valid_hwnd(self._autosplit_ref.hwnd) + + def subscribe_to_new_frame(self, callback: Callable[[MatLike | None], object]): + self._subscriptions.append(callback) + + def unsubscribe_from_new_frame(self, callback: Callable[[MatLike | None], object]): + self._subscriptions.remove(callback) + + def _push_new_frame_to_subscribers(self, frame: MatLike | None): + for subscription in self._subscriptions: + subscription(frame) + + +class ThreadedLoopCaptureMethod(CaptureMethodBase, metaclass=ABCMeta): + def __init__(self, autosplit: "AutoSplit"): + super().__init__(autosplit) + self.__capture_timer = QtCore.QTimer() + self.__capture_timer.setTimerType(QtCore.Qt.TimerType.PreciseTimer) + self.__capture_timer.timeout.connect(self.__read_loop) + self.__capture_timer.start(int(ONE_SECOND / self._autosplit_ref.settings_dict["fps_limit"])) + + @override + def close(self): + self.__capture_timer.stop() + + @override + def set_fps_limit(self, fps: int): + if fps > QTIMER_FPS_LIMIT: + raise ValueError(f"QTimer supports a resolution of maximum {QTIMER_FPS_LIMIT} FPS") + if fps < 0: + raise ValueError("'fps' must be positive or 0") + interval = 0 if fps == 0 else int(ONE_SECOND / fps) + self.__capture_timer.setInterval(interval) + + @abstractmethod + def _read_action(self) -> MatLike | None: + """The synchronous code that requests a new image from the operating system.""" + raise NotImplementedError + + def __read_loop(self): + try: + if self.check_selected_region_exists(): + captured_image = self._read_action() + # HACK: When WindowsGraphicsCaptureMethod tries to get images too quickly, + # it'll return the previous image directly to avoid looking like it dropped signal + if captured_image is not self.last_captured_image: + self.last_captured_image = captured_image + self._push_new_frame_to_subscribers(self.last_captured_image) + else: + self.last_captured_image = None + self._push_new_frame_to_subscribers(None) + except Exception as exception: # noqa: BLE001 # We really want to catch everything here + error = exception + self._autosplit_ref.show_error_signal.emit( + lambda: error_messages.exception_traceback( + error, + "AutoSplit encountered an unhandled exception while " + + "trying to grab a frame and has stopped capture. " + + error_messages.CREATE_NEW_ISSUE_MESSAGE, + ), + ) + self.close() diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 0551ff20..6e15a6dd 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -34,11 +34,12 @@ def __init__(self, autosplit: "AutoSplit"): self.desktop_duplication = d3dshot.create(capture_output="numpy") @override - def get_frame(self): + def _read_action(self): selection = self._autosplit_ref.settings_dict["capture_region"] hwnd = self._autosplit_ref.hwnd hmonitor = ctypes.windll.user32.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) - if not hmonitor or not self.check_selected_region_exists(): + # if not hmonitor or not self.check_selected_region_exists(): + if not hmonitor: return None left_bounds, top_bounds, *_ = get_window_bounds(hwnd) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 04d37f1d..84765f23 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -1,4 +1,3 @@ -from threading import Event, Thread from typing import TYPE_CHECKING import cv2 @@ -8,8 +7,7 @@ from pygrabber.dshow_graph import FilterGraph from typing_extensions import override -from capture_method.CaptureMethodBase import CaptureMethodBase -from error_messages import CREATE_NEW_ISSUE_MESSAGE, exception_traceback +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import ImageShape, is_valid_image if TYPE_CHECKING: @@ -31,7 +29,7 @@ def is_blank(image: MatLike): ) -class VideoCaptureDeviceCaptureMethod(CaptureMethodBase): +class VideoCaptureDeviceCaptureMethod(ThreadedLoopCaptureMethod): name = "Video Capture Device" short_description = "see below" description = ( @@ -40,57 +38,10 @@ class VideoCaptureDeviceCaptureMethod(CaptureMethodBase): ) capture_device: cv2.VideoCapture - capture_thread: Thread | None = None - stop_thread: Event - last_captured_frame: MatLike | None = None - last_converted_frame: MatLike | None = None - is_old_image = False - - def __read_loop(self): - try: - while not self.stop_thread.is_set(): - try: - result, image = self.capture_device.read() - except cv2.error as cv2_error: - if not ( - cv2_error.code == cv2.Error.STS_ERROR - and ( - # Likely means the camera is occupied OR the camera index is out of range (like -1) - cv2_error.msg.endswith("in function 'cv::VideoCapture::grab'\n") - # Some capture cards we cannot use directly - # https://github.com/opencv/opencv/issues/23539 - or cv2_error.msg.endswith("in function 'cv::VideoCapture::retrieve'\n") - ) - ): - raise - result = False - image = None - if not result: - image = None - - # Blank frame. Reuse the previous one. - if image is not None and is_blank(image): - continue - - self.last_captured_frame = image - self.is_old_image = False - except Exception as exception: # noqa: BLE001 # We really want to catch everything here - error = exception - self.capture_device.release() - self._autosplit_ref.show_error_signal.emit( - lambda: exception_traceback( - error, - "AutoSplit encountered an unhandled exception while " - + "trying to grab a frame and has stopped capture. " - + CREATE_NEW_ISSUE_MESSAGE, - ), - ) def __init__(self, autosplit: "AutoSplit"): - super().__init__(autosplit) self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) self.capture_device.setExceptionMode(True) - self.stop_thread = Event() # The video capture device isn't accessible, don't bother with it. if not self.capture_device.isOpened(): @@ -108,41 +59,45 @@ def __init__(self, autosplit: "AutoSplit"): except cv2.error: # Some cameras don't allow changing the resolution pass - self.capture_thread = Thread(target=self.__read_loop) - self.capture_thread.start() + super().__init__(autosplit) @override def close(self): - self.stop_thread.set() - if self.capture_thread: - self.capture_thread.join() - self.capture_thread = None + super().close() self.capture_device.release() @override - def get_frame(self): - if not self.check_selected_region_exists(): + def _read_action(self): + # if not self.check_selected_region_exists(): + # return None, False + try: + result, image = self.capture_device.read() + except cv2.error as cv2_error: + if not ( + cv2_error.code == cv2.Error.STS_ERROR + and ( + # Likely means the camera is occupied OR the camera index is out of range (like -1) + cv2_error.msg.endswith("in function 'cv::VideoCapture::grab'\n") + # Some capture cards we cannot use directly + # https://github.com/opencv/opencv/issues/23539 + or cv2_error.msg.endswith("in function 'cv::VideoCapture::retrieve'\n") + ) + ): + raise return None - image = self.last_captured_frame - is_old_image = self.is_old_image - self.is_old_image = True - if not is_valid_image(image): + if not result or not is_valid_image(image) or is_blank(image): return None - if is_old_image: - return self.last_converted_frame - selection = self._autosplit_ref.settings_dict["capture_region"] # Ensure we can't go OOB of the image y = min(selection["y"], image.shape[ImageShape.Y] - 1) x = min(selection["x"], image.shape[ImageShape.X] - 1) image = image[ - y : y + selection["height"], - x : x + selection["width"], + y: y + selection["height"], + x: x + selection["width"], ] - self.last_converted_frame = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) - return self.last_converted_frame + return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) @override def check_selected_region_exists(self): diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 465a4349..10d4faa1 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -11,7 +11,7 @@ from winsdk.windows.graphics.directx import DirectXPixelFormat from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import BGRA_CHANNEL_COUNT, WGC_MIN_BUILD, WINDOWS_BUILD_NUMBER, get_direct3d_device, is_valid_hwnd if TYPE_CHECKING: @@ -21,8 +21,10 @@ LEARNING_MODE_DEVICE_BUILD = 17763 """https://learn.microsoft.com/en-us/uwp/api/windows.ai.machinelearning.learningmodeldevice""" +WGC_QTIMER_LIMIT = 30 -class WindowsGraphicsCaptureMethod(CaptureMethodBase): + +class WindowsGraphicsCaptureMethod(ThreadedLoopCaptureMethod): name = "Windows Graphics Capture" short_description = "fast, most compatible, capped at 60fps" description = ( @@ -38,7 +40,6 @@ class WindowsGraphicsCaptureMethod(CaptureMethodBase): frame_pool: Direct3D11CaptureFramePool | None = None session: GraphicsCaptureSession | None = None """This is stored to prevent session from being garbage collected""" - last_converted_frame: MatLike | None = None def __init__(self, autosplit: "AutoSplit"): super().__init__(autosplit) @@ -68,6 +69,7 @@ def __init__(self, autosplit: "AutoSplit"): @override def close(self): + super().close() if self.frame_pool: self.frame_pool.close() self.frame_pool = None @@ -82,13 +84,22 @@ def close(self): self.session = None @override - def get_frame(self) -> MatLike | None: + def set_fps_limit(self, fps: int): + """ + There's an issue in the interraction between QTimer and WGC API where setting the interval to even 1 ms + causes twice as many "called `try_get_next_frame` to fast. + So for FPS target above 30, we unlock interval speed. + """ + super().set_fps_limit(fps if fps <= WGC_QTIMER_LIMIT else 0) + + @override + def _read_action(self) -> MatLike | None: selection = self._autosplit_ref.settings_dict["capture_region"] # We still need to check the hwnd because WGC will return a blank black image if not ( - self.check_selected_region_exists() + # self.check_selected_region_exists() and # Only needed for the type-checker - and self.frame_pool + self.frame_pool ): return None @@ -100,7 +111,7 @@ def get_frame(self) -> MatLike | None: # We were too fast and the next frame wasn't ready yet if not frame: - return self.last_converted_frame + return self.last_captured_image async def coroutine(): return await SoftwareBitmap.create_copy_from_surface_async(frame.surface) @@ -110,13 +121,13 @@ async def coroutine(): except SystemError as exception: # HACK: can happen when closing the GraphicsCapturePicker if str(exception).endswith("returned a result with an error set"): - return self.last_converted_frame + return self.last_captured_image raise if not software_bitmap: # HACK: Can happen when starting the region selector # TODO: Validate if this is still true - return self.last_converted_frame + return self.last_captured_image # raise ValueError("Unable to convert Direct3D11CaptureFrame to SoftwareBitmap.") bitmap_buffer = software_bitmap.lock_buffer(BitmapBufferAccessMode.READ_WRITE) if not bitmap_buffer: @@ -124,12 +135,10 @@ async def coroutine(): reference = bitmap_buffer.create_reference() image = np.frombuffer(cast(bytes, reference), dtype=np.uint8) image.shape = (self.size.height, self.size.width, BGRA_CHANNEL_COUNT) - image = image[ - selection["y"] : selection["y"] + selection["height"], - selection["x"] : selection["x"] + selection["width"], + return image[ + selection["y"]: selection["y"] + selection["height"], + selection["x"]: selection["x"] + selection["width"], ] - self.last_converted_frame = image - return image @override def recover_window(self, captured_window_title: str): diff --git a/src/menu_bar.py b/src/menu_bar.py index f4107a27..e1f9cb20 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -232,6 +232,7 @@ def __fps_limit_changed(self, value: int): value = self.fps_limit_spinbox.value() self._autosplit_ref.settings_dict["fps_limit"] = value self._autosplit_ref.timer_live_image.setInterval(int(ONE_SECOND / value)) + self._autosplit_ref.capture_method.set_fps_limit(value) @fire_and_forget def __set_all_capture_devices(self): diff --git a/src/region_selection.py b/src/region_selection.py index 9e1ef6bd..832c300a 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -201,7 +201,7 @@ def align_region(autosplit: "AutoSplit"): # Obtaining the capture of a region which contains the # subregion being searched for to align the image. - capture = autosplit.capture_method.get_frame() + capture = autosplit.capture_method.last_captured_image if not is_valid_image(capture): error_messages.region() diff --git a/src/utils.py b/src/utils.py index f7b5676c..503ca8a3 100644 --- a/src/utils.py +++ b/src/utils.py @@ -26,6 +26,8 @@ ONE_SECOND = 1000 """1000 milliseconds in 1 second""" +QTIMER_FPS_LIMIT = 1000 +"""QTimers are accurate to the millisecond""" DWMWA_EXTENDED_FRAME_BOUNDS = 9 MAXBYTE = 255 BGR_CHANNEL_COUNT = 3 From 912a5e3a5503e8b3a321f7775dc964891c7e5554 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 16:29:15 -0500 Subject: [PATCH 02/28] Renormalize line endigns to LF --- .editorconfig | 28 +-- .gitattributes | 6 + docs/2.0.0_gif.gif | Bin 14239080 -> 14238784 bytes docs/mask_example_image.png | Bin 136683 -> 136682 bytes res/btn_donateCC_LG.png | Bin 2701 -> 2700 bytes res/icon.ico | Bin 15881 -> 15880 bytes res/splash.png | Bin 30994 -> 30993 bytes src/AutoSplitImage.py | 330 +++++++++++++++--------------- src/error_messages.py | 392 ++++++++++++++++++------------------ 9 files changed, 381 insertions(+), 375 deletions(-) create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig index cd5c1f1c..f7970eae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,14 +1,14 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true -charset = utf-8 -indent_style = space -indent_size = 2 - -[*.py] -indent_size = 4 - -[*.ui] -indent_size = 1 +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.ui] +indent_size = 1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..051f3af1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Normalize EOF +* autocrlf=false +* eol=lf +# Set linguist-language to support comments syntax highlight +pyrightconfig*.json linguist-language=jsonc +.vscode/*.json linguist-language=jsonc diff --git a/docs/2.0.0_gif.gif b/docs/2.0.0_gif.gif index b06242dcaf732c446491c9f7788c417ea18b3bd9..f7cd3345d962b25c62e602a3caa9b38c96573683 100644 GIT binary patch delta 2194 zcmWm9`9IYA0tet1**U@}q{28emJl_>MFTyx^=tX`@{1GJQIR(x%P~4xl5gX4PAc6WE;E!n>8C$4@yfl zs7DD(QB0Hi7z2?&DxL<#(^6{*@nc9+=D?33^QjyjJu;tuC!8r6aHeF%hIpnXSK&r@ zo8-XTByX05%kShf_28haaHgM(jVfSlRPkXk8e^3<89}X7Y3LnZXDZWGpje=+DNh!c zC{t9hxa7CC8a(w-r4?dkTy@Vn5^Oye!PaVaD^R#cO;eWC9#BK=0rgoKQW>L8(LiMk z?Num*k7!>d;E6OXJ#gi*W@S5qi{>quLA-E2D;#xS=UE_i8!%Uuf=Zb6^E?pr-HIqF{YBw@#O5q5lGHkW*tTZHd&i?#QF7PZ*D6+u>d z*ZiQ(*9(#++)O=+KDe0%)c#?X0fUZj4F={%!CX!+bVhp&U7t=~6#Snd<#)U&U_3Iz z^_z@4dyu}2$yUG(Hj{3JR}su26_OOoq!=M7)_7YWUJV&*F!1%evGzr%oi};ldL)`Ns7rb%5tQbux#XU&5||f3K5sJk&E&7EMX3!QmpkYkbB8` zLVzPHZCt(4C9>Hrfr;}9`94@_*q+uyzm;un8+LrOE%$_iqaFJfod0BVII8$73BVpACAJjis^+XQGM^@1}xUA#qSd4!+x^}7o-L7xWpd@_tB4@PJtp2eZ zO_c=O*jJku2{^jY7VG9ovnJddZ6y%_2 z=kb7sQ4f!uzHln{(C1;B(OT|Kj0)FkMq>Ks+6V!*WO%xo;f%yHZ#CFIJl~ukbm2PC zh3jWFk$DsADPEKf6mQI%@CggW5qDqh1NnK^ebb=Z>?;Dl3fUw?B4Pfx2nU3 zRzUnRT&5o7xe<{vpcO`__YuafJz(tGo5w`Y_oC-Pg&K~LF zL+__?NED;0g|vzoZan#+$Hfo5SfM)+gvUY<9`|W6j`HJHr~{|sq@~eR9H+kzqe}6m zA4$TGc*l>@jPC+uQER4Po8!rXFU#1S0U z6Zw~5uvOHt4EY~LMxCTha-4F4l7%)&_HYjJ*5pVN6!zsP8RG0rPUUm-SGAy`UTEG7_PxUI zO0XU&oX!Aq>!}CE7f`F*w zlR2&nEC1<7K~UB0!zA=^6+$0hNU6a1(Q5Z1Sn6Ih2*(EA#oAsXmL!^{cmb2k>Jpq zlx_rv-WnD++Vm*(5l&wZQ$}A oXUNeQ`x1t(PQqyCu={W1VAL=UM*Tf5g1_{Lel+4@Mn2sCAIn%&$p8QV delta 2795 zcmWlbc{r6@8^*7VaJFPlC~dQnLc2^Q5{Zo@heV-4hDyj(oKo7Rtwe^fQIul{A6gP2 zV-(8Vc-5(BP`DhWzP`SD{c&Hv_1@3(4r@KnT6?#|zZ0HJd?#Gh=E@MSX>(0p1UPy& z$^v4+0t`SrB(Z=YPBLW}GhtwSA|px$6yP)LxeRfxqlN$^NE|hU=B~lF>=v3+hi^C~ zL=2}E<*C6Nk`yl90WZ>Bm_)jZtVxAe{7EEfC8#GRdVK(~!Dpgm@Yx?va%n7r>8=I- z&SwsP0*8{sR2PHunPRdc$Yth>L5m49U%a^j{sYbunQZX+yCs2+h}&$CAT}E$jSbo8O2QiY;uDIL-EM+Nur1MNS zS1NbFJ?0mci223km%}lBBD{h;5msIni1^1~W!rloTYu#p)PhXKuiPhq9J{2I5R2F) zskjTnFHTj_Pz8HxRCNCWzZs}zTY{IuR9W!3RavT#8l+CDvNXomAePXf(L>+iY@lf( z3i3=eRrSHj0L`4mxW_g^6Vf8K5!wel;a3c6OKX75KeU&XgQGWAz5EQ;rLE4^1RWYy ze|v-btnzfAHN+}Ucf}H1`;_StpR%=~^t(qY){>Embu!w>|1i9+OB!C)Mf$CNEM8KY~c1F7F)Z1NkN zH8ZiG?^$ncGPMF^9W;r-nh<`FNhb$9*km%~0~)bRJ25ZvKULGo1K|4$rU`eztnBtiR5>xt_i+kg8C+9FOB+yCoFeR&%~KUv7b=ByqVmu$23E7)~v zhaHxhD}8?lk-l#$sDQEDGg~6}%+7%>?SZwNRoFfx*?=QT-#_)k6X7?yIEjH@yIfi@6>jBs7gG8C z&zXl97j(Fif(|z`Bg9|+a$8QP`a^fmycswhxMznia*T)eK<>nNXzxe5v(=vN^?wBN zZhC}NqFSz=DW4L_2UIDAY zqAo8{Nw9p>%V-$6Z_B+QALsyiTYj){46%Z^L&o%g8xK0n(gGJU4?BWfo94sBrrDP} zhjD++5z=4d=kX13oihJnLwwdh{zBEb6iN>Wq6G=f3XnptbK`siNu2M|3^qKa+@p(r zz++_`gN_qcM$o^z;LCjodht23|*i<3t#r1{Im%i&pr8N8tmeQEunoq zToe`_h0oxu3@4nG5j@*ziQ}PqXo~9 zXu-2=I+JRTvzoWTQR&!*6vR>sW1({-weY+XJ?!3eo*ya%Zy$;4PeMFyE{?>_UD%q1 zaaS^zbS1|ZC?f8AGyYyDsBtWzl6L0MKtg&wcqAcl8XKJJR-6bW5x3&xl0noelGm<@ zaC<)Q4DHx$g}i`huu?h2EguxQmvY|#d@!A&)rQ-rYc4{Upj0wlb9p2UvCi#j0$i}W zFO8`NKAA~7cK~#8NiV|2;x4>OCkwBxl#0N+K5<0>?Z<7D${>wWnco}W)jrHz7y>H_ zv*d9OllGddI$Gq&?^*S~bZRicoPlSzB6 zikFi_m&>#!hg<+>jdPEqF}U6yxy0KeFOC-VeNglm2I_@M<6xUUvuN;6;bsy}*MfDAY&mP_sfek zRc{2lH#d2jfEp1^)tSf{k+=snLoHA%8IicJMAv4f^uE$faPh9TVR}xyzR;FV>v&SU z{XOlKz1{84u|0^PYlkd)fotd80d*2P@2-(N_$t4Fb6b5S_lBR~GP@lDz|T zBR^H?ouGZvs@c1w4D5g0`?C-1DR?-AwdE52BSQFn%u0+~?fXfq{lJS6jQQ%1p(li| zK3M34{4HUFFX;cyGC4LQ(loEkY4ISw+Nht0A6#P-y1{RdFs(1@)#p2G8nU#F8}U;F+K(#B-r diff --git a/docs/mask_example_image.png b/docs/mask_example_image.png index c2e942274e11e0ab5e590958d15bb0dbe127a461..b51b0b648d9a7ef4e2ce5a712f3575b4ac06b607 100644 GIT binary patch delta 21 ccmaF8n&Z`K4wlXUKlet~R#wKXtW3{D0beQyb^rhX delta 22 dcmaF0n&b6q4%W^9KX=|nmR1(Vtt?E>L;+-A2Y~

8P0?wNd(=EdUyH1gii5 delta 12 TcmeCE>8zO`#mKu+>YXhBARYv` diff --git a/res/splash.png b/res/splash.png index 2858bdb90148d137e0f1b986f24c8ff507cee7e6..c0afe62ea0669a24c96ea15ec5250d19b1083927 100644 GIT binary patch delta 15 WcmbRAiE-j5MwZS1KlhESyp;enTLriP delta 16 XcmbREiE+{=M%K;%KX=}ZEWDKfIUEJW diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py index 4b32888b..d4176560 100644 --- a/src/AutoSplitImage.py +++ b/src/AutoSplitImage.py @@ -1,165 +1,165 @@ -import os -from enum import IntEnum, auto -from math import sqrt -from typing import TYPE_CHECKING - -import cv2 -import numpy as np -from cv2.typing import MatLike - -import error_messages -from compare import check_if_image_has_transparency, get_comparison_method_by_index -from utils import BGR_CHANNEL_COUNT, MAXBYTE, ColorChannel, ImageShape, is_valid_image - -if TYPE_CHECKING: - from AutoSplit import AutoSplit - - -# Resize to these width and height so that FPS performance increases -COMPARISON_RESIZE_WIDTH = 320 -COMPARISON_RESIZE_HEIGHT = 240 -COMPARISON_RESIZE = (COMPARISON_RESIZE_WIDTH, COMPARISON_RESIZE_HEIGHT) -COMPARISON_RESIZE_AREA = COMPARISON_RESIZE_WIDTH * COMPARISON_RESIZE_HEIGHT -MASK_LOWER_BOUND = np.array([0, 0, 0, 1], dtype="uint8") -MASK_UPPER_BOUND = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") -START_KEYWORD = "start_auto_splitter" -RESET_KEYWORD = "reset" - - -class ImageType(IntEnum): - SPLIT = auto() - RESET = auto() - START = auto() - - -class AutoSplitImage: - path: str - filename: str - flags: int - loops: int - image_type: ImageType - byte_array: MatLike | None = None - mask: MatLike | None = None - # This value is internal, check for mask instead - _has_transparency = False - # These values should be overriden by some Defaults if None. Use getters instead - __delay_time: float | None = None - __comparison_method: int | None = None - __pause_time: float | None = None - __similarity_threshold: float | None = None - - def get_delay_time(self, default: "AutoSplit | int"): - """Get image's delay time or fallback to the default value from spinbox.""" - if self.__delay_time is not None: - return self.__delay_time - if isinstance(default, int): - return default - return default.settings_dict["default_delay_time"] - - def __get_comparison_method_index(self, default: "AutoSplit | int"): - """Get image's comparison or fallback to the default value from combobox.""" - if self.__comparison_method is not None: - return self.__comparison_method - if isinstance(default, int): - return default - return default.settings_dict["default_comparison_method"] - - def get_pause_time(self, default: "AutoSplit | float"): - """Get image's pause time or fallback to the default value from spinbox.""" - if self.__pause_time is not None: - return self.__pause_time - if isinstance(default, (float, int)): - return default - return default.settings_dict["default_pause_time"] - - def get_similarity_threshold(self, default: "AutoSplit | float"): - """Get image's similarity threshold or fallback to the default value from spinbox.""" - if self.__similarity_threshold is not None: - return self.__similarity_threshold - if isinstance(default, (float, int)): - return default - return default.settings_dict["default_similarity_threshold"] - - def __init__(self, path: str): - self.path = path - self.filename = os.path.split(path)[-1].lower() - self.flags = flags_from_filename(self.filename) - self.loops = loop_from_filename(self.filename) - self.__delay_time = delay_time_from_filename(self.filename) - self.__comparison_method = comparison_method_from_filename(self.filename) - self.__pause_time = pause_from_filename(self.filename) - self.__similarity_threshold = threshold_from_filename(self.filename) - self.__read_image_bytes(path) - - if START_KEYWORD in self.filename: - self.image_type = ImageType.START - elif RESET_KEYWORD in self.filename: - self.image_type = ImageType.RESET - else: - self.image_type = ImageType.SPLIT - - def __read_image_bytes(self, path: str): - image = cv2.imread(path, cv2.IMREAD_UNCHANGED) - if not is_valid_image(image): - self.byte_array = None - error_messages.image_type(path) - return - - self._has_transparency = check_if_image_has_transparency(image) - # If image has transparency, create a mask - if self._has_transparency: - # Adaptively determine the target size according to - # the number of nonzero elements in the alpha channel of the split image. - # This may result in images bigger than COMPARISON_RESIZE if there's plenty of transparency. - # Which wouldn't incur any performance loss in methods where masked regions are ignored. - scale = min(1, sqrt(COMPARISON_RESIZE_AREA / cv2.countNonZero(image[:, :, ColorChannel.Alpha]))) - - image = cv2.resize( - image, - dsize=None, - fx=scale, - fy=scale, - interpolation=cv2.INTER_NEAREST, - ) - - # Mask based on adaptively resized, nearest neighbor interpolated split image - self.mask = cv2.inRange(image, MASK_LOWER_BOUND, MASK_UPPER_BOUND) - else: - image = cv2.resize(image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # Add Alpha channel if missing - if image.shape[ImageShape.Channels] == BGR_CHANNEL_COUNT: - image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) - - self.byte_array = image - - def check_flag(self, flag: int): - return self.flags & flag == flag - - def compare_with_capture( - self, - default: "AutoSplit | int", - capture: MatLike | None, - ): - """Compare image with capture using image's comparison method. Falls back to combobox.""" - if not is_valid_image(self.byte_array) or not is_valid_image(capture): - return 0.0 - resized_capture = cv2.resize(capture, self.byte_array.shape[1::-1]) - - return get_comparison_method_by_index( - self.__get_comparison_method_index(default), - )( - self.byte_array, - resized_capture, - self.mask, - ) - - -if True: - from split_parser import ( - comparison_method_from_filename, - delay_time_from_filename, - flags_from_filename, - loop_from_filename, - pause_from_filename, - threshold_from_filename, - ) +import os +from enum import IntEnum, auto +from math import sqrt +from typing import TYPE_CHECKING + +import cv2 +import numpy as np +from cv2.typing import MatLike + +import error_messages +from compare import check_if_image_has_transparency, get_comparison_method_by_index +from utils import BGR_CHANNEL_COUNT, MAXBYTE, ColorChannel, ImageShape, is_valid_image + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +# Resize to these width and height so that FPS performance increases +COMPARISON_RESIZE_WIDTH = 320 +COMPARISON_RESIZE_HEIGHT = 240 +COMPARISON_RESIZE = (COMPARISON_RESIZE_WIDTH, COMPARISON_RESIZE_HEIGHT) +COMPARISON_RESIZE_AREA = COMPARISON_RESIZE_WIDTH * COMPARISON_RESIZE_HEIGHT +MASK_LOWER_BOUND = np.array([0, 0, 0, 1], dtype="uint8") +MASK_UPPER_BOUND = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") +START_KEYWORD = "start_auto_splitter" +RESET_KEYWORD = "reset" + + +class ImageType(IntEnum): + SPLIT = auto() + RESET = auto() + START = auto() + + +class AutoSplitImage: + path: str + filename: str + flags: int + loops: int + image_type: ImageType + byte_array: MatLike | None = None + mask: MatLike | None = None + # This value is internal, check for mask instead + _has_transparency = False + # These values should be overriden by some Defaults if None. Use getters instead + __delay_time: float | None = None + __comparison_method: int | None = None + __pause_time: float | None = None + __similarity_threshold: float | None = None + + def get_delay_time(self, default: "AutoSplit | int"): + """Get image's delay time or fallback to the default value from spinbox.""" + if self.__delay_time is not None: + return self.__delay_time + if isinstance(default, int): + return default + return default.settings_dict["default_delay_time"] + + def __get_comparison_method_index(self, default: "AutoSplit | int"): + """Get image's comparison or fallback to the default value from combobox.""" + if self.__comparison_method is not None: + return self.__comparison_method + if isinstance(default, int): + return default + return default.settings_dict["default_comparison_method"] + + def get_pause_time(self, default: "AutoSplit | float"): + """Get image's pause time or fallback to the default value from spinbox.""" + if self.__pause_time is not None: + return self.__pause_time + if isinstance(default, (float, int)): + return default + return default.settings_dict["default_pause_time"] + + def get_similarity_threshold(self, default: "AutoSplit | float"): + """Get image's similarity threshold or fallback to the default value from spinbox.""" + if self.__similarity_threshold is not None: + return self.__similarity_threshold + if isinstance(default, (float, int)): + return default + return default.settings_dict["default_similarity_threshold"] + + def __init__(self, path: str): + self.path = path + self.filename = os.path.split(path)[-1].lower() + self.flags = flags_from_filename(self.filename) + self.loops = loop_from_filename(self.filename) + self.__delay_time = delay_time_from_filename(self.filename) + self.__comparison_method = comparison_method_from_filename(self.filename) + self.__pause_time = pause_from_filename(self.filename) + self.__similarity_threshold = threshold_from_filename(self.filename) + self.__read_image_bytes(path) + + if START_KEYWORD in self.filename: + self.image_type = ImageType.START + elif RESET_KEYWORD in self.filename: + self.image_type = ImageType.RESET + else: + self.image_type = ImageType.SPLIT + + def __read_image_bytes(self, path: str): + image = cv2.imread(path, cv2.IMREAD_UNCHANGED) + if not is_valid_image(image): + self.byte_array = None + error_messages.image_type(path) + return + + self._has_transparency = check_if_image_has_transparency(image) + # If image has transparency, create a mask + if self._has_transparency: + # Adaptively determine the target size according to + # the number of nonzero elements in the alpha channel of the split image. + # This may result in images bigger than COMPARISON_RESIZE if there's plenty of transparency. + # Which wouldn't incur any performance loss in methods where masked regions are ignored. + scale = min(1, sqrt(COMPARISON_RESIZE_AREA / cv2.countNonZero(image[:, :, ColorChannel.Alpha]))) + + image = cv2.resize( + image, + dsize=None, + fx=scale, + fy=scale, + interpolation=cv2.INTER_NEAREST, + ) + + # Mask based on adaptively resized, nearest neighbor interpolated split image + self.mask = cv2.inRange(image, MASK_LOWER_BOUND, MASK_UPPER_BOUND) + else: + image = cv2.resize(image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) + # Add Alpha channel if missing + if image.shape[ImageShape.Channels] == BGR_CHANNEL_COUNT: + image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) + + self.byte_array = image + + def check_flag(self, flag: int): + return self.flags & flag == flag + + def compare_with_capture( + self, + default: "AutoSplit | int", + capture: MatLike | None, + ): + """Compare image with capture using image's comparison method. Falls back to combobox.""" + if not is_valid_image(self.byte_array) or not is_valid_image(capture): + return 0.0 + resized_capture = cv2.resize(capture, self.byte_array.shape[1::-1]) + + return get_comparison_method_by_index( + self.__get_comparison_method_index(default), + )( + self.byte_array, + resized_capture, + self.mask, + ) + + +if True: + from split_parser import ( + comparison_method_from_filename, + delay_time_from_filename, + flags_from_filename, + loop_from_filename, + pause_from_filename, + threshold_from_filename, + ) diff --git a/src/error_messages.py b/src/error_messages.py index 5583ad8b..c455f265 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -1,196 +1,196 @@ -"""Error messages.""" - -import os -import signal -import sys -import traceback -from types import TracebackType -from typing import TYPE_CHECKING, NoReturn - -from PySide6 import QtCore, QtWidgets - -from utils import FROZEN, GITHUB_REPOSITORY - -if TYPE_CHECKING: - from AutoSplit import AutoSplit - - -def __exit_program(): - # stop main thread (which is probably blocked reading input) via an interrupt signal - os.kill(os.getpid(), signal.SIGINT) - sys.exit(1) - - -def set_text_message(message: str, details: str = "", kill_button: str = "", accept_button: str = ""): - message_box = QtWidgets.QMessageBox() - message_box.setWindowTitle("Error") - message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) - message_box.setText(message) - # Button order is important for default focus - if accept_button: - message_box.addButton(accept_button, QtWidgets.QMessageBox.ButtonRole.AcceptRole) - if kill_button: - force_quit_button = message_box.addButton(kill_button, QtWidgets.QMessageBox.ButtonRole.ResetRole) - force_quit_button.clicked.connect(__exit_program) - if details: - message_box.setDetailedText(details) - # Preopen the details - for button in message_box.buttons(): - if message_box.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole: - button.click() - break - message_box.exec() - - -def split_image_directory(): - set_text_message("No split image folder is selected.") - - -def split_image_directory_not_found(): - set_text_message("The Split Image Folder does not exist.") - - -def split_image_directory_empty(): - set_text_message("The Split Image Folder is empty.") - - -def image_type(image: str): - set_text_message( - f"{image!r} is not a valid image file, does not exist, " - + "or the full image file path contains a special character.", - ) - - -def region(): - set_text_message( - "No region is selected or the Capture Region window is not open. " - + "Select a region or load settings while the Capture Region window is open.", - ) - - -def split_hotkey(): - set_text_message("No split hotkey has been set.") - - -def pause_hotkey(): - set_text_message( - "Your split image folder contains an image filename with a pause flag {p}, but no pause hotkey is set.", - ) - - -def image_validity(image: str = "File"): - set_text_message(f"{image} not a valid image file") - - -def alignment_not_matched(): - set_text_message("No area in capture region matched reference image. Alignment failed.") - - -def no_keyword_image(keyword: str): - set_text_message(f"Your split image folder does not contain an image with the keyword {keyword!r}.") - - -def multiple_keyword_images(keyword: str): - set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") - - -def reset_hotkey(): - set_text_message("Your split image folder contains a Reset Image, but no reset hotkey is set.") - - -def old_version_settings_file(): - set_text_message( - "Old version settings file detected. This version allows settings files in .toml format. Starting from v2.0.", - ) - - -def invalid_settings(): - set_text_message("Invalid settings file.") - - -def invalid_hotkey(hotkey_name: str): - set_text_message(f"Invalid hotkey {hotkey_name!r}") - - -def no_settings_file_on_open(): - set_text_message( - "No settings file found. One can be loaded on open if placed in the same folder as the AutoSplit executable.", - ) - - -def too_many_settings_files_on_open(): - set_text_message( - "Too many settings files found. " - + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable.", - ) - - -def check_for_updates(): - set_text_message("An error occurred while attempting to check for updates. Please check your connection.") - - -def load_start_image(): - set_text_message( - "Start Image found, but cannot be loaded unless Start hotkey is set. " - + "Please set the hotkey, and then click the Reload Start Image button.", - ) - - -def stdin_lost(): - set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") - - -def already_open(): - set_text_message( - "An instance of AutoSplit is already running.
Are you sure you want to open a another one?", - "", - "Don't open", - "Ignore", - ) - - -def exception_traceback(exception: BaseException, message: str = ""): - if not message: - message = ( - "AutoSplit encountered an unhandled exception and will try to recover, " - + f"however, there is no guarantee it will keep working properly. {CREATE_NEW_ISSUE_MESSAGE}" - ) - set_text_message( - message, - "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), - "Close AutoSplit", - ) - - -CREATE_NEW_ISSUE_MESSAGE = ( - f"Please create a New Issue at " - + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " - + "and copy & paste the entire error message below" -) - - -def make_excepthook(autosplit: "AutoSplit"): - def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: TracebackType | None): - # Catch Keyboard Interrupts for a clean close - if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): - sys.exit(0) - # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture - if ( - exception_type is SystemError - and str(exception) == " returned a result with an error set" - ): - return - # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors - autosplit.show_error_signal.emit(lambda: exception_traceback(exception)) - - return excepthook - - -def handle_top_level_exceptions(exception: Exception) -> NoReturn: - message = f"AutoSplit encountered an unrecoverable exception and will likely now close. {CREATE_NEW_ISSUE_MESSAGE}" - # Print error to console if not running in executable - if FROZEN: - exception_traceback(exception, message) - else: - traceback.print_exception(type(exception), exception, exception.__traceback__) - sys.exit(1) +"""Error messages.""" + +import os +import signal +import sys +import traceback +from types import TracebackType +from typing import TYPE_CHECKING, NoReturn + +from PySide6 import QtCore, QtWidgets + +from utils import FROZEN, GITHUB_REPOSITORY + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +def __exit_program(): + # stop main thread (which is probably blocked reading input) via an interrupt signal + os.kill(os.getpid(), signal.SIGINT) + sys.exit(1) + + +def set_text_message(message: str, details: str = "", kill_button: str = "", accept_button: str = ""): + message_box = QtWidgets.QMessageBox() + message_box.setWindowTitle("Error") + message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) + message_box.setText(message) + # Button order is important for default focus + if accept_button: + message_box.addButton(accept_button, QtWidgets.QMessageBox.ButtonRole.AcceptRole) + if kill_button: + force_quit_button = message_box.addButton(kill_button, QtWidgets.QMessageBox.ButtonRole.ResetRole) + force_quit_button.clicked.connect(__exit_program) + if details: + message_box.setDetailedText(details) + # Preopen the details + for button in message_box.buttons(): + if message_box.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole: + button.click() + break + message_box.exec() + + +def split_image_directory(): + set_text_message("No split image folder is selected.") + + +def split_image_directory_not_found(): + set_text_message("The Split Image Folder does not exist.") + + +def split_image_directory_empty(): + set_text_message("The Split Image Folder is empty.") + + +def image_type(image: str): + set_text_message( + f"{image!r} is not a valid image file, does not exist, " + + "or the full image file path contains a special character.", + ) + + +def region(): + set_text_message( + "No region is selected or the Capture Region window is not open. " + + "Select a region or load settings while the Capture Region window is open.", + ) + + +def split_hotkey(): + set_text_message("No split hotkey has been set.") + + +def pause_hotkey(): + set_text_message( + "Your split image folder contains an image filename with a pause flag {p}, but no pause hotkey is set.", + ) + + +def image_validity(image: str = "File"): + set_text_message(f"{image} not a valid image file") + + +def alignment_not_matched(): + set_text_message("No area in capture region matched reference image. Alignment failed.") + + +def no_keyword_image(keyword: str): + set_text_message(f"Your split image folder does not contain an image with the keyword {keyword!r}.") + + +def multiple_keyword_images(keyword: str): + set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") + + +def reset_hotkey(): + set_text_message("Your split image folder contains a Reset Image, but no reset hotkey is set.") + + +def old_version_settings_file(): + set_text_message( + "Old version settings file detected. This version allows settings files in .toml format. Starting from v2.0.", + ) + + +def invalid_settings(): + set_text_message("Invalid settings file.") + + +def invalid_hotkey(hotkey_name: str): + set_text_message(f"Invalid hotkey {hotkey_name!r}") + + +def no_settings_file_on_open(): + set_text_message( + "No settings file found. One can be loaded on open if placed in the same folder as the AutoSplit executable.", + ) + + +def too_many_settings_files_on_open(): + set_text_message( + "Too many settings files found. " + + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable.", + ) + + +def check_for_updates(): + set_text_message("An error occurred while attempting to check for updates. Please check your connection.") + + +def load_start_image(): + set_text_message( + "Start Image found, but cannot be loaded unless Start hotkey is set. " + + "Please set the hotkey, and then click the Reload Start Image button.", + ) + + +def stdin_lost(): + set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") + + +def already_open(): + set_text_message( + "An instance of AutoSplit is already running.
Are you sure you want to open a another one?", + "", + "Don't open", + "Ignore", + ) + + +def exception_traceback(exception: BaseException, message: str = ""): + if not message: + message = ( + "AutoSplit encountered an unhandled exception and will try to recover, " + + f"however, there is no guarantee it will keep working properly. {CREATE_NEW_ISSUE_MESSAGE}" + ) + set_text_message( + message, + "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), + "Close AutoSplit", + ) + + +CREATE_NEW_ISSUE_MESSAGE = ( + f"Please create a New Issue at " + + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " + + "and copy & paste the entire error message below" +) + + +def make_excepthook(autosplit: "AutoSplit"): + def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: TracebackType | None): + # Catch Keyboard Interrupts for a clean close + if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): + sys.exit(0) + # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture + if ( + exception_type is SystemError + and str(exception) == " returned a result with an error set" + ): + return + # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors + autosplit.show_error_signal.emit(lambda: exception_traceback(exception)) + + return excepthook + + +def handle_top_level_exceptions(exception: Exception) -> NoReturn: + message = f"AutoSplit encountered an unrecoverable exception and will likely now close. {CREATE_NEW_ISSUE_MESSAGE}" + # Print error to console if not running in executable + if FROZEN: + exception_traceback(exception, message) + else: + traceback.print_exception(type(exception), exception, exception.__traceback__) + sys.exit(1) From f3e3911d1bb0585240296b20d399914268755486 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 16:52:07 -0500 Subject: [PATCH 03/28] Fix back images --- docs/2.0.0_gif.gif | Bin 14238784 -> 14239080 bytes docs/mask_example_image.png | Bin 136682 -> 136683 bytes res/btn_donateCC_LG.png | Bin 2700 -> 2701 bytes res/icon.ico | Bin 15880 -> 15881 bytes res/splash.png | Bin 30993 -> 30994 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/2.0.0_gif.gif b/docs/2.0.0_gif.gif index f7cd3345d962b25c62e602a3caa9b38c96573683..b06242dcaf732c446491c9f7788c417ea18b3bd9 100644 GIT binary patch delta 2795 zcmWlbc{r6@8^*7VaJFPlC~dQnLc2^Q5{Zo@heV-4hDyj(oKo7Rtwe^fQIul{A6gP2 zV-(8Vc-5(BP`DhWzP`SD{c&Hv_1@3(4r@KnT6?#|zZ0HJd?#Gh=E@MSX>(0p1UPy& z$^v4+0t`SrB(Z=YPBLW}GhtwSA|px$6yP)LxeRfxqlN$^NE|hU=B~lF>=v3+hi^C~ zL=2}E<*C6Nk`yl90WZ>Bm_)jZtVxAe{7EEfC8#GRdVK(~!Dpgm@Yx?va%n7r>8=I- z&SwsP0*8{sR2PHunPRdc$Yth>L5m49U%a^j{sYbunQZX+yCs2+h}&$CAT}E$jSbo8O2QiY;uDIL-EM+Nur1MNS zS1NbFJ?0mci223km%}lBBD{h;5msIni1^1~W!rloTYu#p)PhXKuiPhq9J{2I5R2F) zskjTnFHTj_Pz8HxRCNCWzZs}zTY{IuR9W!3RavT#8l+CDvNXomAePXf(L>+iY@lf( z3i3=eRrSHj0L`4mxW_g^6Vf8K5!wel;a3c6OKX75KeU&XgQGWAz5EQ;rLE4^1RWYy ze|v-btnzfAHN+}Ucf}H1`;_StpR%=~^t(qY){>Embu!w>|1i9+OB!C)Mf$CNEM8KY~c1F7F)Z1NkN zH8ZiG?^$ncGPMF^9W;r-nh<`FNhb$9*km%~0~)bRJ25ZvKULGo1K|4$rU`eztnBtiR5>xt_i+kg8C+9FOB+yCoFeR&%~KUv7b=ByqVmu$23E7)~v zhaHxhD}8?lk-l#$sDQEDGg~6}%+7%>?SZwNRoFfx*?=QT-#_)k6X7?yIEjH@yIfi@6>jBs7gG8C z&zXl97j(Fif(|z`Bg9|+a$8QP`a^fmycswhxMznia*T)eK<>nNXzxe5v(=vN^?wBN zZhC}NqFSz=DW4L_2UIDAY zqAo8{Nw9p>%V-$6Z_B+QALsyiTYj){46%Z^L&o%g8xK0n(gGJU4?BWfo94sBrrDP} zhjD++5z=4d=kX13oihJnLwwdh{zBEb6iN>Wq6G=f3XnptbK`siNu2M|3^qKa+@p(r zz++_`gN_qcM$o^z;LCjodht23|*i<3t#r1{Im%i&pr8N8tmeQEunoq zToe`_h0oxu3@4nG5j@*ziQ}PqXo~9 zXu-2=I+JRTvzoWTQR&!*6vR>sW1({-weY+XJ?!3eo*ya%Zy$;4PeMFyE{?>_UD%q1 zaaS^zbS1|ZC?f8AGyYyDsBtWzl6L0MKtg&wcqAcl8XKJJR-6bW5x3&xl0noelGm<@ zaC<)Q4DHx$g}i`huu?h2EguxQmvY|#d@!A&)rQ-rYc4{Upj0wlb9p2UvCi#j0$i}W zFO8`NKAA~7cK~#8NiV|2;x4>OCkwBxl#0N+K5<0>?Z<7D${>wWnco}W)jrHz7y>H_ zv*d9OllGddI$Gq&?^*S~bZRicoPlSzB6 zikFi_m&>#!hg<+>jdPEqF}U6yxy0KeFOC-VeNglm2I_@M<6xUUvuN;6;bsy}*MfDAY&mP_sfek zRc{2lH#d2jfEp1^)tSf{k+=snLoHA%8IicJMAv4f^uE$faPh9TVR}xyzR;FV>v&SU z{XOlKz1{84u|0^PYlkd)fotd80d*2P@2-(N_$t4Fb6b5S_lBR~GP@lDz|T zBR^H?ouGZvs@c1w4D5g0`?C-1DR?-AwdE52BSQFn%u0+~?fXfq{lJS6jQQ%1p(li| zK3M34{4HUFFX;cyGC4LQ(loEkY4ISw+Nht0A6#P-y1{RdFs(1@)#p2G8nU#F8}U;F+K(#B-r delta 2194 zcmWm9`9IYA0tet1**U@}q{28emJl_>MFTyx^=tX`@{1GJQIR(x%P~4xl5gX4PAc6WE;E!n>8C$4@yfl zs7DD(QB0Hi7z2?&DxL<#(^6{*@nc9+=D?33^QjyjJu;tuC!8r6aHeF%hIpnXSK&r@ zo8-XTByX05%kShf_28haaHgM(jVfSlRPkXk8e^3<89}X7Y3LnZXDZWGpje=+DNh!c zC{t9hxa7CC8a(w-r4?dkTy@Vn5^Oye!PaVaD^R#cO;eWC9#BK=0rgoKQW>L8(LiMk z?Num*k7!>d;E6OXJ#gi*W@S5qi{>quLA-E2D;#xS=UE_i8!%Uuf=Zb6^E?pr-HIqF{YBw@#O5q5lGHkW*tTZHd&i?#QF7PZ*D6+u>d z*ZiQ(*9(#++)O=+KDe0%)c#?X0fUZj4F={%!CX!+bVhp&U7t=~6#Snd<#)U&U_3Iz z^_z@4dyu}2$yUG(Hj{3JR}su26_OOoq!=M7)_7YWUJV&*F!1%evGzr%oi};ldL)`Ns7rb%5tQbux#XU&5||f3K5sJk&E&7EMX3!QmpkYkbB8` zLVzPHZCt(4C9>Hrfr;}9`94@_*q+uyzm;un8+LrOE%$_iqaFJfod0BVII8$73BVpACAJjis^+XQGM^@1}xUA#qSd4!+x^}7o-L7xWpd@_tB4@PJtp2eZ zO_c=O*jJku2{^jY7VG9ovnJddZ6y%_2 z=kb7sQ4f!uzHln{(C1;B(OT|Kj0)FkMq>Ks+6V!*WO%xo;f%yHZ#CFIJl~ukbm2PC zh3jWFk$DsADPEKf6mQI%@CggW5qDqh1NnK^ebb=Z>?;Dl3fUw?B4Pfx2nU3 zRzUnRT&5o7xe<{vpcO`__YuafJz(tGo5w`Y_oC-Pg&K~LF zL+__?NED;0g|vzoZan#+$Hfo5SfM)+gvUY<9`|W6j`HJHr~{|sq@~eR9H+kzqe}6m zA4$TGc*l>@jPC+uQER4Po8!rXFU#1S0U z6Zw~5uvOHt4EY~LMxCTha-4F4l7%)&_HYjJ*5pVN6!zsP8RG0rPUUm-SGAy`UTEG7_PxUI zO0XU&oX!Aq>!}CE7f`F*w zlR2&nEC1<7K~UB0!zA=^6+$0hNU6a1(Q5Z1Sn6Ih2*(EA#oAsXmL!^{cmb2k>Jpq zlx_rv-WnD++Vm*(5l&wZQ$}A oXUNeQ`x1t(PQqyCu={W1VAL=UM*Tf5g1_{Lel+4@Mn2sCAIn%&$p8QV diff --git a/docs/mask_example_image.png b/docs/mask_example_image.png index b51b0b648d9a7ef4e2ce5a712f3575b4ac06b607..c2e942274e11e0ab5e590958d15bb0dbe127a461 100644 GIT binary patch delta 22 dcmaF0n&b6q4%W^9KX=|nmR1(Vtt?E>L;+-A2Y~

8zO`#mKu+>YXhBARYv` delta 10 RcmeCI>8P0?wNd(=EdUyH1gii5 diff --git a/res/splash.png b/res/splash.png index c0afe62ea0669a24c96ea15ec5250d19b1083927..2858bdb90148d137e0f1b986f24c8ff507cee7e6 100644 GIT binary patch delta 16 XcmbREiE+{=M%K;%KX=}ZEWDKfIUEJW delta 15 WcmbRAiE-j5MwZS1KlhESyp;enTLriP From ab6dbdc50b3b8fb564c91cdf8038dd3393679564 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 21:35:25 -0500 Subject: [PATCH 04/28] Important save detection + LiveSplit commands fixes --- scripts/build.ps1 | 2 +- scripts/requirements.txt | 2 +- src/AutoSplit.py | 11 ++++------- src/capture_method/__init__.py | 1 + src/hotkeys.py | 4 ++-- src/split_parser.py | 2 +- src/user_profile.py | 19 ++++++++++++------- src/utils.py | 6 +++--- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index bd6e3be9..94ebd2e9 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -9,7 +9,7 @@ $arguments = @( '--splash=res/splash.png', # The install script should ensure that these are not installed # But we'll still include unused dependencies that would be picked up by PyInstaller - # if requirements.txt was used directly to help ensure consistency when buildign locally. + # if requirements.txt was used directly to help ensure consistency when building locally. # # Installed by PyAutoGUI '--exclude=pyscreeze', diff --git a/scripts/requirements.txt b/scripts/requirements.txt index a0782294..e01f9fcc 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -13,7 +13,7 @@ Pillow>=10.0 # Python 3.12 support psutil>=5.9.6 # Python 3.12 fixes PyAutoGUI PyWinCtl>=0.0.42 # py.typed -# When needed, dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev +# When needed, dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev?C=M;O=D PySide6-Essentials>=6.6.0 # Python 3.12 support requests>=2.28.2 # charset_normalizer 3.x update toml diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 2790d8a5..5633bfd4 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -4,6 +4,7 @@ import signal import sys from collections.abc import Callable +from copy import deepcopy from time import time from types import FunctionType from typing import NoReturn @@ -50,8 +51,6 @@ open_file, ) -DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = 2 - # Needed when compiled, along with the custom hook-requests PyInstaller hook os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" @@ -93,7 +92,7 @@ def __init__(self): # noqa: PLR0915 # Initialize a few attributes self.hwnd = 0 """Window Handle used for Capture Region""" - self.last_saved_settings = DEFAULT_PROFILE + self.last_saved_settings = deepcopy(DEFAULT_PROFILE) self.similarity = 0.0 self.split_image_number = 0 self.split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] @@ -101,10 +100,8 @@ def __init__(self): # noqa: PLR0915 self.capture_method = CaptureMethodBase(self) self.is_running = False - # Last loaded settings empty and last successful loaded settings file path to None until we try to load them - self.last_loaded_settings = DEFAULT_PROFILE - self.last_successfully_loaded_settings_file_path: str | None = None - """For when a file has never loaded, but you successfully "Save File As".""" + self.last_successfully_loaded_settings_file_path = "" + """Path of the settings file to default to. `None` until we try to load once.""" # Automatic timer start self.highest_similarity = 0.0 diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index bb58bc14..de7e2581 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -47,6 +47,7 @@ class CaptureMethodEnum(Enum, metaclass=CaptureMethodMeta): def __repr__(self): return self.value + # Allow direct comparison with strings @override def __eq__(self, other: object): if isinstance(other, str): diff --git a/src/hotkeys.py b/src/hotkeys.py index 7dec1a16..34f4519e 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -52,7 +52,7 @@ def send_command(autosplit: "AutoSplit", command: Commands): # having the reset image check be active at all time would be a better, more organic solution, # but that is dependent on migrating to an observer pattern (#219) and being able to reload all images. match command: - case _ if autosplit.settings_dict["start_also_resets"]: + case _ if autosplit.is_auto_controlled: if command == "start" and autosplit.settings_dict["start_also_resets"]: print("reset", flush=True) print(command, flush=True) @@ -164,7 +164,7 @@ def __get_hotkey_name(names: list[str]): Uses keyboard.get_hotkey_name but works with non-english modifiers and keypad See: https://github.com/boppreh/keyboard/issues/516 . """ - if not names: + if not names: # 0-length return "" if len(names) == 1: diff --git a/src/split_parser.py b/src/split_parser.py index 88b85bc5..acdf6dd7 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -31,7 +31,7 @@ def __value_from_filename( raise ValueError("delimiters parameter must contain exactly 2 characters") try: string_value = filename.split(delimiters[0], 1)[1].split(delimiters[1])[0] - value: T = type(default_value)(string_value) + value = type(default_value)(string_value) except (IndexError, ValueError): return default_value else: diff --git a/src/user_profile.py b/src/user_profile.py index c9ff4d46..f874e04e 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -1,8 +1,10 @@ import os +from copy import deepcopy from typing import TYPE_CHECKING, TypedDict, cast import toml from PySide6 import QtCore, QtWidgets +from typing_extensions import deprecated, override import error_messages from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method @@ -40,6 +42,10 @@ class UserProfileDict(TypedDict): captured_window_title: str capture_region: Region + @override # pyright: ignore + @deprecated("Use `copy.deepcopy` instead") + def copy(): return super().copy() + DEFAULT_PROFILE = UserProfileDict( split_hotkey="", @@ -70,10 +76,7 @@ class UserProfileDict(TypedDict): def have_settings_changed(autosplit: "AutoSplit"): - return ( - autosplit.settings_dict != autosplit.last_saved_settings - or autosplit.settings_dict != autosplit.last_loaded_settings - ) + return autosplit.settings_dict != autosplit.last_saved_settings def save_settings(autosplit: "AutoSplit"): @@ -95,6 +98,7 @@ def save_settings_as(autosplit: "AutoSplit"): or os.path.join(auto_split_directory, "settings.toml"), "TOML (*.toml)", )[0] + # If user cancels save destination window, don't save settings if not save_settings_file_path: return "" @@ -103,10 +107,10 @@ def save_settings_as(autosplit: "AutoSplit"): def __save_settings_to_file(autosplit: "AutoSplit", save_settings_file_path: str): - autosplit.last_saved_settings = autosplit.settings_dict # Save settings to a .toml file with open(save_settings_file_path, "w", encoding="utf-8") as file: - toml.dump(autosplit.last_saved_settings, file) + toml.dump(autosplit.settings_dict, file) + autosplit.last_saved_settings = deepcopy(autosplit.settings_dict) autosplit.last_successfully_loaded_settings_file_path = save_settings_file_path return save_settings_file_path @@ -120,9 +124,10 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s # Casting here just so we can build an actual UserProfileDict once we're done validating # Fallback to default settings if some are missing from the file. This happens when new settings are added. loaded_settings = DEFAULT_PROFILE | cast(UserProfileDict, toml.load(file)) + # TODO: Data Validation / fallbacks ? autosplit.settings_dict = UserProfileDict(**loaded_settings) - autosplit.last_loaded_settings = autosplit.settings_dict + autosplit.last_saved_settings = deepcopy(autosplit.settings_dict) autosplit.x_spinbox.setValue(autosplit.settings_dict["capture_region"]["x"]) autosplit.y_spinbox.setValue(autosplit.settings_dict["capture_region"]["y"]) diff --git a/src/utils.py b/src/utils.py index 503ca8a3..c3507c3a 100644 --- a/src/utils.py +++ b/src/utils.py @@ -31,9 +31,9 @@ DWMWA_EXTENDED_FRAME_BOUNDS = 9 MAXBYTE = 255 BGR_CHANNEL_COUNT = 3 -"""How many channels in an RGB image""" +"""How many channels in a BGR image""" BGRA_CHANNEL_COUNT = 4 -"""How many channels in an RGBA image""" +"""How many channels in a BGRA image""" class ImageShape(IntEnum): @@ -68,7 +68,7 @@ def is_valid_image(image: MatLike | None) -> TypeGuard[MatLike]: return image is not None and bool(image.size) -def is_valid_hwnd(hwnd: int) -> bool: +def is_valid_hwnd(hwnd: int): """Validate the hwnd points to a valid window and not the desktop or whatever window obtained with `""`.""" if not hwnd: return False From e73a5549ffe66702003616143adf54bf486ab06d Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 21:47:50 -0500 Subject: [PATCH 05/28] Merge back linux changes --- .github/workflows/lint-and-build.yml | 15 ++-- .pre-commit-config.yaml | 4 - .vscode/settings.json | 2 + .vscode/tasks.json | 22 +++++ README.md | 35 ++++++++ docs/build instructions.md | 7 +- scripts/build.ps1 | 20 ++++- scripts/designer.ps1 | 9 +- scripts/install.ps1 | 56 ++++++++++-- scripts/requirements-dev.txt | 1 + scripts/requirements.txt | 4 + scripts/start.ps1 | 3 +- src/AutoSplit.py | 15 +++- src/capture_method/BitBltCaptureMethod.py | 4 + .../DesktopDuplicationCaptureMethod.py | 4 + .../ForceFullContentRenderingCaptureMethod.py | 4 + .../Screenshot using QT attempt.py | 36 ++++++++ src/capture_method/ScrotCaptureMethod.py | 50 +++++++++++ .../VideoCaptureDeviceCaptureMethod.py | 27 +++--- .../WindowsGraphicsCaptureMethod.py | 4 + src/capture_method/XDisplayCaptureMethod.py | 65 ++++++++++++++ src/capture_method/__init__.py | 90 ++++++++++++++----- src/error_messages.py | 34 +++++++ src/hotkeys.py | 27 +++++- src/menu_bar.py | 14 ++- src/region_selection.py | 81 +++++++++++------ src/utils.py | 46 ++++++++-- 27 files changed, 584 insertions(+), 95 deletions(-) create mode 100644 src/capture_method/Screenshot using QT attempt.py create mode 100644 src/capture_method/ScrotCaptureMethod.py create mode 100644 src/capture_method/XDisplayCaptureMethod.py diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index a5dcd70e..7b734686 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -40,11 +40,12 @@ concurrency: jobs: ruff: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Ruff is version and platform sensible matrix: + os: [windows-latest, ubuntu-22.04] python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} @@ -59,11 +60,12 @@ jobs: shell: pwsh - run: ruff check . Pyright: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Pyright is version and platform sensible matrix: + os: [windows-latest, ubuntu-22.04] python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} @@ -82,12 +84,13 @@ jobs: working-directory: src/ python-version: ${{ matrix.python-version }} Build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false # Only the Python version we plan on shipping matters. matrix: - python-version: ["3.11", "3.12"] + os: [windows-latest, ubuntu-22.04] + python-version: ["3.11"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} uses: actions/checkout@v3 @@ -104,13 +107,13 @@ jobs: - name: Upload Build Artifact uses: actions/upload-artifact@v3 with: - name: AutoSplit (Python ${{ matrix.python-version }}) + name: AutoSplit for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: dist/AutoSplit* if-no-files-found: error - name: Upload Build logs uses: actions/upload-artifact@v3 with: - name: Build logs (Python ${{ matrix.python-version }}) + name: Build logs for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: | build/AutoSplit/*.toc build/AutoSplit/*.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffd3a619..2e5a5187 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,3 @@ repos: ci: autoupdate_branch: dev autoupdate_schedule: monthly - skip: - # Ignore until Linux support. We don't want lf everywhere yet - # And crlf fails on CI because pre-commit runs on linux - - "mixed-line-ending" diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c75e33f..5adca23d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -97,6 +97,8 @@ "powershell.codeFormatting.whitespaceBetweenParameters": true, "powershell.integratedConsole.showOnStartup": false, "terminal.integrated.defaultProfile.windows": "PowerShell", + "terminal.integrated.defaultProfile.linux": "pwsh", + "terminal.integrated.defaultProfile.osx": "pwsh", "xml.codeLens.enabled": true, "xml.format.spaceBeforeEmptyCloseTag": false, "xml.format.preserveSpace": [ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 65ac2dc5..a38720e1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,6 +13,28 @@ } } }, + "linux": { + "options": { + "shell": { + "executable": "pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, + "osx": { + "options": { + "shell": { + "executable": "pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, "tasks": [ { "label": "Compile resources", diff --git a/README.md b/README.md index c8a4e856..f651eb03 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,29 @@ This program can be used to automatically start, split, and reset your preferred - You can also check out the [latest dev builds](/../../actions/workflows/lint-and-build.yml?query=event%3Apush+is%3Asuccess) (requires a GitHub account) (If you don't have a GitHub account, you can try [nightly.link](https://nightly.link/Toufool/AutoSplit/workflows/lint-and-build/dev)) +- Linux users must ensure they are in the `tty` and `input` groups and have write access to `/dev/uinput`. You can run the following commands to do so: + + + + ```shell + sudo usermod -a -G tty,input $USER + sudo touch /dev/uinput + sudo chmod +0666 /dev/uinput + echo 'KERNEL=="uinput", TAG+="uaccess""' | sudo tee /etc/udev/rules.d/50-uinput.rules + echo 'SUBSYSTEM=="input", MODE="0666" GROUP="plugdev"' | sudo tee /etc/udev/rules.d/12-input.rules + echo 'SUBSYSTEM=="misc", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + echo 'SUBSYSTEM=="tty", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + loginctl terminate-user $USER + ``` + + + All screen capture method are incompatible with Wayland. Follow [this guide](https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop) to disable it. + ### Compatibility - Windows 10 and 11. +- Linux (Only tested on Ubuntu 22.04) + - Wayland is not supported - Python 3.10+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). ## OPTIONS @@ -70,6 +90,8 @@ This program can be used to automatically start, split, and reset your preferred #### Capture Method +##### Windows + - **Windows Graphics Capture** (fast, most compatible, capped at 60fps) Only available in Windows 10.0.17134 and up. Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled. @@ -88,6 +110,19 @@ This program can be used to automatically start, split, and reset your preferred - **Force Full Content Rendering** (very slow, can affect rendering) Uses BitBlt behind the scene, but passes a special flag to PrintWindow to force rendering the entire desktop. About 10-15x slower than BitBlt based on original window size and can mess up some applications' rendering pipelines. + +##### Linux + +- **XDisplay** (fast, requires xcb) + Uses X to take screenshots of the display. +- **Scrot** (very slow, may leave files) + Uses Scrot (SCReenshOT) to take screenshots. + Leaves behind a screenshot file if interrupted. + + "scrot" must be installed: `sudo apt-get install scrot` + +##### All platforms + - **Video Capture Device** Uses a Video Capture Device, like a webcam, virtual cam, or capture card. diff --git a/docs/build instructions.md b/docs/build instructions.md index 3d700c87..bcf29568 100644 --- a/docs/build instructions.md +++ b/docs/build instructions.md @@ -6,12 +6,17 @@ - Microsoft Visual C++ 14.0 or greater may be required to build the executable. Get it with [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +### Linux + +- You need to be part of the `input` and `tty` groups, as well as have permissions on a few files and folders. + If you are missing from either groups, the install script will take care of it on its first run, but you'll need to restart your session. + ### All platforms - [Python](https://www.python.org/downloads/) 3.10+. - [Node](https://nodejs.org) is optional, but required for complete linting. - Alternatively you can install the [pyright python wrapper](https://pypi.org/project/pyright/) which has a bit of an overhead delay. -- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) +- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) is used to run all the scripts - [VSCode](https://code.visualstudio.com/Download) is not required, but highly recommended. - Everything already configured in the workspace, including Run (F5) and Build (Ctrl+Shift+B) commands, default shell, and recommended extensions. - [PyCharm](https://www.jetbrains.com/pycharm/) is also a good Python IDE, but nothing is configured. If you are a PyCharm user, feel free to open a PR with all necessary workspace configurations! diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 94ebd2e9..fc284fe0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -12,12 +12,30 @@ $arguments = @( # if requirements.txt was used directly to help ensure consistency when building locally. # # Installed by PyAutoGUI - '--exclude=pyscreeze', '--exclude=pygetwindow', '--exclude=pymsgbox', '--exclude=pytweening', '--exclude=mouseinfo', # Used by imagehash.whash '--exclude=pywt') +if ($IsWindows) { + # Installed by PyAutoGUI + $arguments += '--exclude=pyscreeze' +} +if ($IsLinux) { + $arguments += @( + # Required on the CI for PyWinCtl + '--hidden-import pynput.keyboard._xorg', + '--hidden-import pynput.mouse._xorg') +} Start-Process -Wait -NoNewWindow pyinstaller -ArgumentList $arguments + +If ($IsLinux) { + Move-Item -Force $PSScriptRoot/../dist/AutoSplit $PSScriptRoot/../dist/AutoSplit.elf + If ($?) { + Write-Host 'Added .elf extension' + } + chmod +x $PSScriptRoot/../dist/AutoSplit.elf + Write-Host 'Added execute permission' +} diff --git a/scripts/designer.ps1 b/scripts/designer.ps1 index a6a159f6..df9ff7e5 100644 --- a/scripts/designer.ps1 +++ b/scripts/designer.ps1 @@ -1,10 +1,13 @@ +$python = $IsLinux ? 'python3' : 'python' $qt6_applications_import = 'import qt6_applications; print(qt6_applications.__path__[0])' -$qt6_applications_path = python -c $qt6_applications_import + +$qt6_applications_path = &"$python" -c $qt6_applications_import if ($null -eq $qt6_applications_path) { Write-Host 'Designer not found, installing qt6_applications' - python -m pip install qt6_applications + &"$python" -m pip install qt6_applications } -$qt6_applications_path = python -c $qt6_applications_import + +$qt6_applications_path = &"$python" -c $qt6_applications_import & "$qt6_applications_path/Qt/bin/designer" ` "$PSScriptRoot/../res/design.ui" ` "$PSScriptRoot/../res/about.ui" ` diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 140af292..762c2c1d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,50 @@ +$python = $IsLinux ? 'python3' : 'python' + +# Validating user groups on Linux +If ($IsLinux) { + $groups = groups + if ($groups.Contains('input') -and $groups.Contains('tty')) { + Write-Host "User $Env:USER is already part of groups input and tty. No actions taken." + } + Else { + # https://github.com/boppreh/keyboard/issues/312#issuecomment-1189734564 + Write-Host "User $Env:USER isn't part of groups input and tty. It is required to install the keyboard module." + # Keep in sync with README.md and src/error_messages.py + sudo usermod -a -G 'tty,input' $Env:USER + sudo touch /dev/uinput + sudo chmod +0666 /dev/uinput + If (-not $Env:GITHUB_JOB) { + Write-Output 'KERNEL=="uinput", TAG+="uaccess""' | sudo tee /etc/udev/rules.d/50-uinput.rules + Write-Output 'SUBSYSTEM=="input", MODE="0666" GROUP="plugdev"' | sudo tee /etc/udev/rules.d/12-input.rules + Write-Output 'SUBSYSTEM=="misc", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + Write-Output 'SUBSYSTEM=="tty", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules + } + Write-Host 'You have been added automatically,' ` + "but still need to manually terminate your session with 'loginctl terminate-user $Env:USER'" ` + 'for the changes to take effect outside of this script.' + If (-not $Env:GITHUB_JOB) { + Write-Host -NoNewline 'Press any key to continue...'; + $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown'); + } + } +} + # Installing Python dependencies $dev = If ($Env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } +If ($IsLinux) { + If (-not $Env:GITHUB_JOB -or $Env:GITHUB_JOB -eq 'Build') { + sudo apt-get update + # python3-tk for splash screen, npm for pyright + sudo apt-get install -y python3-pip python3-tk npm + # Helps ensure build machine has the required PySide6 libraries for all target machines. + # Not everything here is required, but using the documentation from + # https://wiki.qt.io/Building_Qt_5_from_Git#Libxcb + # TODO: Test if still necessary with PySide6 + sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev + } +} # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. -python -m pip install wheel pip setuptools --upgrade +&"$python" -m pip install wheel pip setuptools --upgrade pip install -r "$PSScriptRoot/requirements$dev.txt" --upgrade # These libraries install extra requirements we don't want # Open suggestion for support in requirements files: https://github.com/pypa/pip/issues/9948 & https://github.com/pypa/pip/pull/10837 @@ -15,20 +58,23 @@ pip install PyAutoGUI ImageHash scipy --no-deps --upgrade # Prevent PyAutoGUI and pywinctl from setting Process DPI Awareness, which Qt tries to do then throws warnings about it. # The unittest workaround significantly increases build time, boot time and build size with PyInstaller. # https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464 -$libPath = python -c 'import pyautogui as _; print(_.__path__[0])' +$libPath = &"$python" -c 'import pyautogui as _; print(_.__path__[0])' (Get-Content "$libPath/_pyautogui_win.py").replace('ctypes.windll.user32.SetProcessDPIAware()', 'pass') | Set-Content "$libPath/_pyautogui_win.py" -$libPath = python -c 'import pymonctl as _; print(_.__path__[0])' +$libPath = &"$python" -c 'import pymonctl as _; print(_.__path__[0])' (Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | Set-Content "$libPath/_pymonctl_win.py" -$libPath = python -c 'import pywinbox as _; print(_.__path__[0])' +$libPath = &"$python" -c 'import pywinbox as _; print(_.__path__[0])' (Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | Set-Content "$libPath/_pywinbox_win.py" # Uninstall optional dependencies if PyAutoGUI was installed outside this script # pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness # pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 # (also --exclude from build script, but more consistent with unfrozen run) -python -m pip uninstall pyscreeze pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y +pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y +If (-not $IsLinux) { + pip uninstall pyscreeze +} # Don't compile resources on the Build CI job as it'll do so in build script diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 6d233d8e..0e354f68 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -23,6 +23,7 @@ types-Pillow types-psutil types-PyAutoGUI types-pyinstaller +types-python-xlib ; sys_platform == 'linux' types-pywin32 ; sys_platform == 'win32' types-requests types-toml diff --git a/scripts/requirements.txt b/scripts/requirements.txt index e01f9fcc..33ad5096 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -30,3 +30,7 @@ pygrabber>=0.2 ; sys_platform == 'win32' # Completed types pywin32>=301 ; sys_platform == 'win32' winsdk>=1.0.0b10 ; sys_platform == 'win32' # Python 3.12 support git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot ; sys_platform == 'win32' # D3DShot from PyPI with Pillow>=7.2.0 will install 0.1.3 instead of 0.1.5 +# +# Linux-only dependencies +pyscreeze ; sys_platform == 'linux' +python-xlib ; sys_platform == 'linux' diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 70d6fd8b..f12d9f8e 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -1,3 +1,4 @@ param ([string]$p1) & "$PSScriptRoot/compile_resources.ps1" -python "$PSScriptRoot/../src/AutoSplit.py" $p1 +$python = $IsLinux ? 'python3' : 'python' +&"$python" "$PSScriptRoot/../src/AutoSplit.py" $p1 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 5633bfd4..90f1fa1e 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -24,7 +24,7 @@ from AutoSplitImage import START_KEYWORD, AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker -from hotkeys import HOTKEYS, after_setting_hotkey, send_command +from hotkeys import HOTKEYS, KEYBOARD_GROUPS_ISSUE, KEYBOARD_UINPUT_ISSUE, after_setting_hotkey, send_command from menu_bar import ( about_qt, about_qt_for_python, @@ -42,6 +42,7 @@ AUTOSPLIT_VERSION, BGRA_CHANNEL_COUNT, FROZEN, + IS_WAYLAND, ONE_SECOND, QTIMER_FPS_LIMIT, auto_split_directory, @@ -53,8 +54,9 @@ # Needed when compiled, along with the custom hook-requests PyInstaller hook os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() -myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" -ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) +if sys.platform == "win32": + myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) class AutoSplit(QMainWindow, design.Ui_MainWindow): @@ -954,6 +956,7 @@ def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" +# TODO: Add Linux support def is_already_open(): # When running directly in Python, any AutoSplit process means it's already open # When bundled, we must ignore itself and the splash screen @@ -978,6 +981,12 @@ def main(): if is_already_open(): error_messages.already_open() + if KEYBOARD_GROUPS_ISSUE: + error_messages.linux_groups() + if KEYBOARD_UINPUT_ISSUE: + error_messages.linux_uinput() + if IS_WAYLAND: + error_messages.linux_wayland() AutoSplit() diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 94dc667f..901c5e57 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError import ctypes import ctypes.wintypes diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 6e15a6dd..f76fed24 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError import ctypes from typing import TYPE_CHECKING, cast diff --git a/src/capture_method/ForceFullContentRenderingCaptureMethod.py b/src/capture_method/ForceFullContentRenderingCaptureMethod.py index ebc4cc40..1d546141 100644 --- a/src/capture_method/ForceFullContentRenderingCaptureMethod.py +++ b/src/capture_method/ForceFullContentRenderingCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError from capture_method.BitBltCaptureMethod import BitBltCaptureMethod diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py new file mode 100644 index 00000000..b87b8caf --- /dev/null +++ b/src/capture_method/Screenshot using QT attempt.py @@ -0,0 +1,36 @@ +# flake8: noqa +import sys + +if sys.platform != "linux": + raise OSError() +from typing import TYPE_CHECKING, cast + +import cv2 +import numpy as np +from cv2.typing import MatLike +from PySide6.QtCore import QBuffer, QIODeviceBase +from PySide6.QtGui import QGuiApplication +from capture_method.CaptureMethodBase import CaptureMethodBase +from typing_extensions import override + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +class ScrotCaptureMethod(CaptureMethodBase): + _render_full_content = False + + @override + def get_frame(self): + buffer = QBuffer() + buffer.open(QIODeviceBase.OpenModeFlag.ReadWrite) + winid = self._autosplit_ref.winId() + test = QGuiApplication.primaryScreen().grabWindow(winid, 0, 0, 200, 200) + image = test.toImage() + b = image.bits() + # sip.voidptr must know size to support python buffer interface + # b.setsize(200 * 200 * 3) + frame = np.frombuffer(cast(MatLike, b), np.uint8).reshape((200, 200, 3)) + + # frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return frame diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py new file mode 100644 index 00000000..349562ea --- /dev/null +++ b/src/capture_method/ScrotCaptureMethod.py @@ -0,0 +1,50 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +import pyscreeze +from typing_extensions import override +from Xlib.display import Display + +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from utils import is_valid_image + + +class ScrotCaptureMethod(ThreadedLoopCaptureMethod): + name = "Scrot" + short_description = "very slow, may leave files" + description = ( + "\nUses Scrot (SCReenshOT) to take screenshots. " + + "\nLeaves behind a screenshot file if interrupted. " + ) + + @override + def _read_action(self): + if not self.check_selected_region_exists(): + return None + xdisplay = Display() + root = xdisplay.screen().root + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + offset_x = data["x"] + offset_y = data["y"] + selection = self._autosplit_ref.settings_dict["capture_region"] + image = pyscreeze.screenshot( + None, + ( + selection["x"] + offset_x, + selection["y"] + offset_y, + selection["width"], + selection["height"], + ), + ) + return np.array(image) + + @override + def get_frame(self): + image = super().get_frame() + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 84765f23..d838a128 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -1,15 +1,18 @@ +import sys from typing import TYPE_CHECKING import cv2 import cv2.Error import numpy as np from cv2.typing import MatLike -from pygrabber.dshow_graph import FilterGraph from typing_extensions import override from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import ImageShape, is_valid_image +if sys.platform == "win32": + from pygrabber.dshow_graph import FilterGraph + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -47,18 +50,18 @@ def __init__(self, autosplit: "AutoSplit"): if not self.capture_device.isOpened(): return - filter_graph = FilterGraph() - filter_graph.add_video_input_device(autosplit.settings_dict["capture_device_id"]) - width, height = filter_graph.get_input_device().get_current_format() - filter_graph.remove_filters() - # Ensure we're using the right camera size. And not OpenCV's default 640x480 - try: - self.capture_device.set(cv2.CAP_PROP_FRAME_WIDTH, width) - self.capture_device.set(cv2.CAP_PROP_FRAME_HEIGHT, height) - except cv2.error: - # Some cameras don't allow changing the resolution - pass + if sys.platform == "win32": + filter_graph = FilterGraph() + filter_graph.add_video_input_device(autosplit.settings_dict["capture_device_id"]) + width, height = filter_graph.get_input_device().get_current_format() + filter_graph.remove_filters() + try: + self.capture_device.set(cv2.CAP_PROP_FRAME_WIDTH, width) + self.capture_device.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + except cv2.error: + # Some cameras don't allow changing the resolution + pass super().__init__(autosplit) @override diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 10d4faa1..77a43f3c 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -1,3 +1,7 @@ +import sys + +if sys.platform != "win32": + raise OSError import asyncio from typing import TYPE_CHECKING, cast diff --git a/src/capture_method/XDisplayCaptureMethod.py b/src/capture_method/XDisplayCaptureMethod.py new file mode 100644 index 00000000..b3209460 --- /dev/null +++ b/src/capture_method/XDisplayCaptureMethod.py @@ -0,0 +1,65 @@ +import sys + +if sys.platform != "linux": + raise OSError + +import cv2 +import numpy as np +from PIL import ImageGrab +from typing_extensions import override +from Xlib.display import Display + +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod +from utils import is_valid_image + + +class XDisplayCaptureMethod(ThreadedLoopCaptureMethod): + name = "XDisplay" + short_description = "fast, requires xcb" + description = "\nUses XCB to take screenshots of the display" + + _xdisplay: str | None = "" # ":0" + + @override + def _read_action(self): + if not self.check_selected_region_exists(): + return None + xdisplay = Display() + root = xdisplay.screen().root + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + offset_x = data["x"] + offset_y = data["y"] + # image = window.get_image(selection["x"], selection["y"], selection["width"], selection["height"], 1, 0) + + selection = self._autosplit_ref.settings_dict["capture_region"] + x = selection["x"] + offset_x + y = selection["y"] + offset_y + image = ImageGrab.grab( + ( + x, + y, + x + selection["width"], + y + selection["height"], + ), + xdisplay=self._xdisplay, + ) + return np.array(image) + + @override + def get_frame(self): + image = super().get_frame() + if not is_valid_image(image): + return None + return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) + + @override + def recover_window(self, captured_window_title: str): + xdisplay = Display() + root = xdisplay.screen().root + children = root.query_tree().children + for window in children: + wm_class = window.get_wm_class() + if wm_class and wm_class[1] == captured_window_title: + self._autosplit_ref.hwnd = window.id + return self.check_selected_region_exists() + return False diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index de7e2581..46648da2 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -1,22 +1,35 @@ import asyncio +import os +import sys from collections import OrderedDict from dataclasses import dataclass from enum import Enum, EnumMeta, auto, unique from itertools import starmap from typing import TYPE_CHECKING, NoReturn, TypedDict, cast -from _ctypes import COMError -from pygrabber.dshow_graph import FilterGraph from typing_extensions import Never, override -from capture_method.BitBltCaptureMethod import BitBltCaptureMethod from capture_method.CaptureMethodBase import CaptureMethodBase -from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod -from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod -from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod from utils import WGC_MIN_BUILD, WINDOWS_BUILD_NUMBER, first, try_get_direct3d_device +if sys.platform == "win32": + from _ctypes import COMError + from pygrabber.dshow_graph import FilterGraph + + from capture_method.BitBltCaptureMethod import BitBltCaptureMethod + from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod + from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod + from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod + +if sys.platform == "linux": + import pyscreeze + from PIL import features + + from capture_method.ScrotCaptureMethod import ScrotCaptureMethod + from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod + + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -71,6 +84,8 @@ def _generate_next_value_(name: "str | CaptureMethodEnum", *_): WINDOWS_GRAPHICS_CAPTURE = auto() PRINTWINDOW_RENDERFULLCONTENT = auto() DESKTOP_DUPLICATION = auto() + SCROT = auto() + XDISPLAY = auto() VIDEO_CAPTURE_DEVICE = auto() @@ -115,22 +130,34 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS = CaptureMethodDict() -if ( # Windows Graphics Capture requires a minimum Windows Build - WINDOWS_BUILD_NUMBER >= WGC_MIN_BUILD - # Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice - and try_get_direct3d_device() -): - CAPTURE_METHODS[CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE] = WindowsGraphicsCaptureMethod -CAPTURE_METHODS[CaptureMethodEnum.BITBLT] = BitBltCaptureMethod -try: # Test for laptop cross-GPU Desktop Duplication issue - import d3dshot - - d3dshot.create(capture_output="numpy") -except (ModuleNotFoundError, COMError): - pass -else: - CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = DesktopDuplicationCaptureMethod -CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod +if sys.platform == "win32": + if ( # Windows Graphics Capture requires a minimum Windows Build + WINDOWS_BUILD_NUMBER >= WGC_MIN_BUILD + # Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice + and try_get_direct3d_device() + ): + CAPTURE_METHODS[CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE] = WindowsGraphicsCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.BITBLT] = BitBltCaptureMethod + try: # Test for laptop cross-GPU Desktop Duplication issue + import d3dshot + + d3dshot.create(capture_output="numpy") + except (ModuleNotFoundError, COMError): + pass + else: + CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = DesktopDuplicationCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod +elif sys.platform == "linux": + if features.check_feature(feature="xcb"): + CAPTURE_METHODS[CaptureMethodEnum.XDISPLAY] = XDisplayCaptureMethod + try: + pyscreeze.screenshot() + except NotImplementedError: + pass + else: + # TODO: Investigate solution for Slow Scrot: + # https://github.com/asweigart/pyscreeze/issues/68 + CAPTURE_METHODS[CaptureMethodEnum.SCROT] = ScrotCaptureMethod CAPTURE_METHODS[CaptureMethodEnum.VIDEO_CAPTURE_DEVICE] = VideoCaptureDeviceCaptureMethod @@ -160,7 +187,24 @@ class CameraInfo: resolution: tuple[int, int] +def get_input_devices(): + if sys.platform == "win32": + return FilterGraph().get_input_devices() + + cameras: list[str] = [] + if sys.platform == "linux": + try: + for index in range(len(os.listdir("/sys/class/video4linux"))): + with open(f"/sys/class/video4linux/video{index}/name", encoding="utf-8") as file: + cameras.append(file.readline()[:-2]) + except FileNotFoundError: + pass + return cameras + + def get_input_device_resolution(index: int): + if sys.platform != "win32": + return (0, 0) filter_graph = FilterGraph() try: filter_graph.add_video_input_device(index) @@ -175,7 +219,7 @@ def get_input_device_resolution(index: int): async def get_all_video_capture_devices(): - named_video_inputs = FilterGraph().get_input_devices() + named_video_inputs = get_input_devices() async def get_camera_info(index: int, device_name: str): backend = "" diff --git a/src/error_messages.py b/src/error_messages.py index c455f265..9d5df4fe 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -149,6 +149,40 @@ def already_open(): ) +def linux_groups(): + set_text_message( + "Linux users must ensure they are in the 'tty' and 'input' groups " + + "and have write access to '/dev/uinput'. You can run the following commands to do so:", + # Keep in sync with README.md and scripts/install.ps1 + "sudo usermod -a -G tty,input $USER" + + "\nsudo touch /dev/uinput" + + "\nsudo chmod +0666 /dev/uinput" + + "\necho 'KERNEL==\"uinput\", TAG+=\"uaccess\"' | sudo tee /etc/udev/rules.d/50-uinput.rules" + + "\necho 'SUBSYSTEM==\"input\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee /etc/udev/rules.d/12-input.rules" + + "\necho 'SUBSYSTEM==\"misc\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" + + "\necho 'SUBSYSTEM==\"tty\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" + + "\nloginctl terminate-user $USER", + ) + + +def linux_uinput(): + set_text_message( + "Failed to create a device file using `uinput` module. " + + "This can happen when runnign Linux under WSL. " + + "Keyboard events have been disabled.", + ) + + +# Keep in sync with README.md#DOWNLOAD_AND_OPEN +WAYLAND_WARNING = "All screen capture method are incompatible with Wayland. Follow this guide to disable it: " \ + + '\n' \ + + "https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop" + + +def linux_wayland(): + set_text_message(WAYLAND_WARNING) + + def exception_traceback(exception: BaseException, message: str = ""): if not message: message = ( diff --git a/src/hotkeys.py b/src/hotkeys.py index 34f4519e..6c1676fc 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,3 +1,4 @@ +import sys from collections.abc import Callable from typing import TYPE_CHECKING, Literal, cast @@ -6,7 +7,19 @@ from PySide6 import QtWidgets import error_messages -from utils import fire_and_forget, is_digit +from utils import fire_and_forget, is_digit, try_input_device_access + +if sys.platform == "linux": + import grp + import os + + # https://github.com/PyCQA/pylint/issues/7240 + groups = {grp.getgrgid(group).gr_name for group in os.getgroups()} + KEYBOARD_GROUPS_ISSUE = not {"input", "tty"}.issubset(groups) + KEYBOARD_UINPUT_ISSUE = not try_input_device_access() +else: + KEYBOARD_GROUPS_ISSUE = False + KEYBOARD_UINPUT_ISSUE = False if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -23,7 +36,8 @@ def remove_all_hotkeys(): - keyboard.unhook_all() + if not KEYBOARD_GROUPS_ISSUE and not KEYBOARD_UINPUT_ISSUE: + keyboard.unhook_all() def before_setting_hotkey(autosplit: "AutoSplit"): @@ -246,6 +260,15 @@ def is_valid_hotkey_name(hotkey_name: str): def set_hotkey(autosplit: "AutoSplit", hotkey: Hotkey, preselected_hotkey_name: str = ""): + if KEYBOARD_GROUPS_ISSUE: + if not preselected_hotkey_name: + error_messages.linux_groups() + return + if KEYBOARD_UINPUT_ISSUE: + if not preselected_hotkey_name: + error_messages.linux_uinput() + return + if autosplit.SettingsWidget: # Unfocus all fields cast(QtWidgets.QWidget, autosplit.SettingsWidget).setFocus() diff --git a/src/menu_bar.py b/src/menu_bar.py index e1f9cb20..37dea594 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -1,4 +1,5 @@ import asyncio +import sys import webbrowser from typing import TYPE_CHECKING, Any, cast @@ -28,6 +29,13 @@ from AutoSplit import AutoSplit HALF_BRIGHTNESS = 128 +LINUX_SCREENSHOT_SUPPORT = ( + "\n\n----------------------------------------------------\n\n" + + error_messages.WAYLAND_WARNING + # Keep in sync with README.md#Capture_Method_Linux + + '\n"scrot" must be installed to use SCReenshOT. ' + + "\nRun: sudo apt-get install scrot" +) if sys.platform == "linux" else "" class __AboutWidget(QtWidgets.QWidget, about.Ui_AboutAutoSplitWidget): # noqa: N801 # Private class @@ -164,7 +172,7 @@ def __init__(self, autosplit: "AutoSplit"): "\n\n".join([ f"{method.name} :\n{method.description}" for method in capture_method_values - ]), + ]) + LINUX_SCREENSHOT_SUPPORT, ) # endregion @@ -262,6 +270,10 @@ def __set_readme_link(self): lambda: webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#readme"), ) self.readme_link_button.setStyleSheet("border: 0px; background-color:rgba(0,0,0,0%);") + # TODO: Check if this is still necessary now that fusion theme is used on both + # if sys.platform == "linux": + # geometry = self.readme_link_button.geometry() + # self.readme_link_button.setGeometry(QtCore.QRect(51, 225, geometry.width(), geometry.height())) def __select_screenshot_directory(self): self._autosplit_ref.settings_dict["screenshot_directory"] = QFileDialog.getExistingDirectory( diff --git a/src/region_selection.py b/src/region_selection.py index 832c300a..b5c9dcb9 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -1,6 +1,5 @@ -import ctypes -import ctypes.wintypes import os +import sys from math import ceil from typing import TYPE_CHECKING @@ -11,11 +10,6 @@ from PySide6.QtTest import QTest from pywinctl import getTopWindowAt from typing_extensions import override -from win32 import win32gui -from win32con import SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN -from winsdk._winrt import initialize_with_window -from winsdk.windows.foundation import AsyncStatus, IAsyncOperation -from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker import error_messages from utils import ( @@ -28,12 +22,23 @@ is_valid_image, ) -user32 = ctypes.windll.user32 +if sys.platform == "win32": + import ctypes + from win32 import win32gui + from win32con import SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN + from winsdk._winrt import initialize_with_window + from winsdk.windows.foundation import AsyncStatus, IAsyncOperation + from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker + user32 = ctypes.windll.user32 + +if sys.platform == "linux": + from Xlib.display import Display if TYPE_CHECKING: from AutoSplit import AutoSplit +GNOME_DESKTOP_ICONS_EXTENSION = "@!0,0;BDHF" ALIGN_REGION_THRESHOLD = 0.9 BORDER_WIDTH = 2 SUPPORTED_IMREAD_FORMATS = [ @@ -62,6 +67,8 @@ # TODO: For later as a different picker option def __select_graphics_item(autosplit: "AutoSplit"): # pyright: ignore [reportUnusedFunction] """Uses the built-in GraphicsCapturePicker to select the Window.""" + if sys.platform != "win32": + raise OSError def callback(async_operation: IAsyncOperation[GraphicsCaptureItem], async_status: AsyncStatus): try: @@ -106,7 +113,7 @@ def select_region(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle() + hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -116,10 +123,18 @@ def select_region(autosplit: "AutoSplit"): autosplit.settings_dict["captured_window_title"] = window_text autosplit.capture_method.reinitialize() - left_bounds, top_bounds, *_ = get_window_bounds(hwnd) - window_x, window_y, *_ = win32gui.GetWindowRect(hwnd) - offset_x = window_x + left_bounds - offset_y = window_y + top_bounds + if sys.platform == "win32": + left_bounds, top_bounds, *_ = get_window_bounds(hwnd) + window_x, window_y, *_ = win32gui.GetWindowRect(hwnd) + offset_x = window_x + left_bounds + offset_y = window_y + top_bounds + else: + xdisplay = Display() + root = xdisplay.screen().root + data = root.translate_coords(autosplit.hwnd, 0, 0)._data # noqa: SLF001 + offset_x = data["x"] + offset_y = data["y"] + __set_region_values( autosplit, left=x - offset_x, @@ -147,7 +162,7 @@ def select_window(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle() + hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -157,11 +172,18 @@ def select_window(autosplit: "AutoSplit"): autosplit.settings_dict["captured_window_title"] = window_text autosplit.capture_method.reinitialize() - # Exlude the borders and titlebar from the window selection. To only get the client area. - _, __, window_width, window_height = get_window_bounds(hwnd) - _, __, client_width, client_height = win32gui.GetClientRect(hwnd) - border_width = ceil((window_width - client_width) / 2) - titlebar_with_border_height = window_height - client_height - border_width + if sys.platform == "win32": + # Exlude the borders and titlebar from the window selection. To only get the client area. + _, __, window_width, window_height = get_window_bounds(hwnd) + _, __, client_width, client_height = win32gui.GetClientRect(hwnd) + border_width = ceil((window_width - client_width) / 2) + titlebar_with_border_height = window_height - client_height - border_width + else: + data = window.getHandle().get_geometry()._data # noqa: SLF001 + client_height = data["height"] + client_width = data["width"] + border_width = data["border_width"] + titlebar_with_border_height = border_width __set_region_values( autosplit, @@ -310,12 +332,21 @@ def __init__(self): super().__init__() # We need to pull the monitor information to correctly draw the geometry covering all portions # of the user's screen. These parameters create the bounding box with left, top, width, and height - self.setGeometry( - user32.GetSystemMetrics(SM_XVIRTUALSCREEN), - user32.GetSystemMetrics(SM_YVIRTUALSCREEN), - user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), - user32.GetSystemMetrics(SM_CYVIRTUALSCREEN), - ) + if sys.platform == "win32": + self.setGeometry( + user32.GetSystemMetrics(SM_XVIRTUALSCREEN), + user32.GetSystemMetrics(SM_YVIRTUALSCREEN), + user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), + user32.GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + else: + data = Display().screen().root.get_geometry()._data # noqa: SLF001 + self.setGeometry( + data["x"], + data["y"], + data["width"], + data["height"], + ) self.setWindowTitle(" ") self.setWindowOpacity(0.5) self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) diff --git a/src/utils.py b/src/utils.py index c3507c3a..fae6c11d 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,7 +1,6 @@ import asyncio -import ctypes -import ctypes.wintypes import os +import subprocess import sys from collections.abc import Callable, Iterable from enum import IntEnum @@ -10,14 +9,22 @@ from threading import Thread from typing import TYPE_CHECKING, Any, TypeGuard, TypeVar -import win32ui from cv2.typing import MatLike -from win32 import win32gui -from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind -from winsdk.windows.media.capture import MediaCapture from gen.build_vars import AUTOSPLIT_BUILD_NUMBER, AUTOSPLIT_GITHUB_REPOSITORY +if sys.platform == "win32": + import ctypes + import ctypes.wintypes + + import win32ui + from win32 import win32gui + from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind + from winsdk.windows.media.capture import MediaCapture + +if sys.platform == "linux": + import fcntl + if TYPE_CHECKING: # Source does not exist, keep this under TYPE_CHECKING from _win32typing import PyCDC # pyright: ignore[reportMissingModuleSource] @@ -90,6 +97,9 @@ def try_delete_dc(dc: "PyCDC"): def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: + if sys.platform != "win32": + raise OSError + extended_frame_bounds = ctypes.wintypes.RECT() ctypes.windll.dwmapi.DwmGetWindowAttribute( hwnd, @@ -107,7 +117,11 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: def open_file(file_path: str | bytes | os.PathLike[str] | os.PathLike[bytes]): - os.startfile(file_path) # noqa: S606 + if sys.platform == "win32": + os.startfile(file_path) # noqa: S606 + else: + opener = "xdg-open" if sys.platform == "linux" else "open" + subprocess.call([opener, file_path]) # noqa: S603 def get_or_create_eventloop(): @@ -120,13 +134,15 @@ def get_or_create_eventloop(): def get_direct3d_device(): + if sys.platform != "win32": + raise OSError("Direct3D Device is only available on Windows") + # Note: Must create in the same thread (can't use a global) otherwise when ran from LiveSplit it will raise: # OSError: The application called an interface that was marshalled for a different thread media_capture = MediaCapture() async def init_mediacapture(): await media_capture.initialize_async() - asyncio.run(init_mediacapture()) direct_3d_device = media_capture.media_capture_settings and media_capture.media_capture_settings.direct3_d11_device if not direct_3d_device: @@ -148,6 +164,19 @@ def try_get_direct3d_device(): return None +def try_input_device_access(): + """Same as `make_uinput` in `keyboard/_nixcommon.py`.""" + if sys.platform != "linux": + return False + try: + UI_SET_EVBIT = 0x40045564 # noqa: N806 + with open("/dev/uinput", "wb") as uinput: + fcntl.ioctl(uinput, UI_SET_EVBIT) + except OSError: + return False + return True + + def fire_and_forget(func: Callable[..., Any]): """ Runs synchronous function asynchronously without waiting for a response. @@ -182,6 +211,7 @@ def flatten(nested_iterable: Iterable[Iterable[T]]) -> chain[T]: """Running from build made by PyInstaller""" auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) """The directory of either the AutoSplit executable or AutoSplit.py""" +IS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY", False)) # Shared strings # Check `excludeBuildNumber` during workflow dispatch build generate a clean version number From d6d75c2b87b167f33b168e03c3dfd5c9143cdf69 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 8 Dec 2023 21:51:17 -0500 Subject: [PATCH 06/28] mixed-line-ending --fix=lf --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e5a5187..e75d93a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: mixed-line-ending - args: [--fix=crlf] + args: [--fix=lf] - id: check-case-conflict - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.11.0 From 9c7b2ae430bca18b1fc1a7329c4b0497f4da4b1d Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Dec 2023 05:09:04 -0500 Subject: [PATCH 07/28] All scripts and WSL GUI work --- .vscode/settings.json | 4 +- README.md | 5 ++- docs/CONTRIBUTING.md | 2 +- scripts/install.ps1 | 38 ++++++++++--------- scripts/linux_build_and_install_python.bash | 28 ++++++++++++++ scripts/requirements.txt | 3 +- .../Screenshot using QT attempt.py | 12 ++---- src/capture_method/ScrotCaptureMethod.py | 6 +-- .../VideoCaptureDeviceCaptureMethod.py | 3 +- src/capture_method/XDisplayCaptureMethod.py | 6 +-- src/capture_method/__init__.py | 6 +-- src/error_messages.py | 2 +- src/region_selection.py | 12 ++++-- src/utils.py | 2 + 14 files changed, 79 insertions(+), 50 deletions(-) create mode 100644 scripts/linux_build_and_install_python.bash diff --git a/.vscode/settings.json b/.vscode/settings.json index 5adca23d..815e9679 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,9 +21,9 @@ "editor.tabSize": 2, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true, + "source.fixAll": "explicit", // Let dedicated linter (Ruff) organize imports - "source.organizeImports": false, + "source.organizeImports": "never" }, "emeraldwalk.runonsave": { "commands": [ diff --git a/README.md b/README.md index f651eb03..695f63cd 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ This program can be used to automatically start, split, and reset your preferred - Windows 10 and 11. - Linux (Only tested on Ubuntu 22.04) - - Wayland is not supported + - Wayland is not currently supported + - WSL2/WSLg requires an additional Desktop Environment, external X11 server, and/or systemd - Python 3.10+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). ## OPTIONS @@ -286,6 +287,8 @@ Not a developer? You can still help through the following methods: - Sharing AutoSplit with other speedrunners - Upvoting the following upstream issues in libraries and tools we use: - + - + - - - - diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 41f66bbe..d6066670 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,4 +1,4 @@ - + # Contributing guidelines diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 762c2c1d..7735670d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -34,13 +34,8 @@ $dev = If ($Env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } If ($IsLinux) { If (-not $Env:GITHUB_JOB -or $Env:GITHUB_JOB -eq 'Build') { sudo apt-get update - # python3-tk for splash screen, npm for pyright - sudo apt-get install -y python3-pip python3-tk npm - # Helps ensure build machine has the required PySide6 libraries for all target machines. - # Not everything here is required, but using the documentation from - # https://wiki.qt.io/Building_Qt_5_from_Git#Libxcb - # TODO: Test if still necessary with PySide6 - sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev + # python3-tk for splash screen, npm for pyright, the rest for PySide6 + sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon } } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. @@ -61,21 +56,28 @@ pip install PyAutoGUI ImageHash scipy --no-deps --upgrade $libPath = &"$python" -c 'import pyautogui as _; print(_.__path__[0])' (Get-Content "$libPath/_pyautogui_win.py").replace('ctypes.windll.user32.SetProcessDPIAware()', 'pass') | Set-Content "$libPath/_pyautogui_win.py" -$libPath = &"$python" -c 'import pymonctl as _; print(_.__path__[0])' -(Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pymonctl_win.py" -$libPath = &"$python" -c 'import pywinbox as _; print(_.__path__[0])' -(Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pywinbox_win.py" +If ($IsWindows) { + $libPath = &"$python" -c 'import pymonctl as _; print(_.__path__[0])' + (Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | + Set-Content "$libPath/_pymonctl_win.py" + $libPath = &"$python" -c 'import pywinbox as _; print(_.__path__[0])' + (Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | + Set-Content "$libPath/_pywinbox_win.py" + pip uninstall pyscreeze +} +# Because Ubuntu 22.04 is forced to use an older version of PySide6, we do a dirty typing patch +# https://bugreports.qt.io/browse/QTBUG-114635 +If ($IsLinux) { + $libPath = &"$python" -c 'import PySide6 as _; print(_.__path__[0])' + (Get-Content "$libPath/QtWidgets.pyi").replace('-> Tuple:', '-> Tuple[str, ...]:') | + Set-Content "$libPath/QtWidgets.pyi" +} # Uninstall optional dependencies if PyAutoGUI was installed outside this script -# pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness +# pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed # pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 # (also --exclude from build script, but more consistent with unfrozen run) pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y -If (-not $IsLinux) { - pip uninstall pyscreeze -} - +If ($IsWindows) { pip uninstall pyscreeze } # Don't compile resources on the Build CI job as it'll do so in build script If ($dev) { diff --git a/scripts/linux_build_and_install_python.bash b/scripts/linux_build_and_install_python.bash new file mode 100644 index 00000000..20c82e75 --- /dev/null +++ b/scripts/linux_build_and_install_python.bash @@ -0,0 +1,28 @@ +cd .. + +# Update package lists +sudo apt update + +# Install dependent libraries: +sudo apt install build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev curl libbz2-dev tk-dev + +# Download Python binary package: +wget https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tgz + +# Unzip the package: +tar -xzf Python-3.10.13.tgz + +# Execute configure script +cd Python-3.10.13 +./configure --enable-optimizations --enable-shared + +# Build Python 3.10 +make -j 2 + +# Install Python 3.10 +sudo make install + +# Verify the installation +python3.10 -V + +echo "If Python version did not print, you may need to stop active processes" diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 33ad5096..8871a35d 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -14,7 +14,8 @@ psutil>=5.9.6 # Python 3.12 fixes PyAutoGUI PyWinCtl>=0.0.42 # py.typed # When needed, dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev?C=M;O=D -PySide6-Essentials>=6.6.0 # Python 3.12 support +PySide6-Essentials>=6.6.0 ; sys_platform == 'win32' # Python 3.12 support +PySide6-Essentials<6.5.1 ; sys_platform == 'linux' # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 requests>=2.28.2 # charset_normalizer 3.x update toml typing-extensions>=4.4.0 # @override decorator support diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py index b87b8caf..7846abd1 100644 --- a/src/capture_method/Screenshot using QT attempt.py +++ b/src/capture_method/Screenshot using QT attempt.py @@ -3,25 +3,21 @@ if sys.platform != "linux": raise OSError() -from typing import TYPE_CHECKING, cast +from typing import cast -import cv2 import numpy as np from cv2.typing import MatLike from PySide6.QtCore import QBuffer, QIODeviceBase from PySide6.QtGui import QGuiApplication -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from typing_extensions import override -if TYPE_CHECKING: - from AutoSplit import AutoSplit - -class ScrotCaptureMethod(CaptureMethodBase): +class QtCaptureMethod(ThreadedLoopCaptureMethod): _render_full_content = False @override - def get_frame(self): + def _read_action(self): buffer = QBuffer() buffer.open(QIODeviceBase.OpenModeFlag.ReadWrite) winid = self._autosplit_ref.winId() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 349562ea..21c54489 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -40,11 +40,7 @@ def _read_action(self): selection["height"], ), ) - return np.array(image) - - @override - def get_frame(self): - image = super().get_frame() + image = np.array(image) if not is_valid_image(image): return None return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index d838a128..4cce4924 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -43,11 +43,13 @@ class VideoCaptureDeviceCaptureMethod(ThreadedLoopCaptureMethod): capture_device: cv2.VideoCapture def __init__(self, autosplit: "AutoSplit"): + super().__init__(autosplit) self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) self.capture_device.setExceptionMode(True) # The video capture device isn't accessible, don't bother with it. if not self.capture_device.isOpened(): + self.close() return # Ensure we're using the right camera size. And not OpenCV's default 640x480 @@ -62,7 +64,6 @@ def __init__(self, autosplit: "AutoSplit"): except cv2.error: # Some cameras don't allow changing the resolution pass - super().__init__(autosplit) @override def close(self): diff --git a/src/capture_method/XDisplayCaptureMethod.py b/src/capture_method/XDisplayCaptureMethod.py index b3209460..7a9aa121 100644 --- a/src/capture_method/XDisplayCaptureMethod.py +++ b/src/capture_method/XDisplayCaptureMethod.py @@ -43,11 +43,7 @@ def _read_action(self): ), xdisplay=self._xdisplay, ) - return np.array(image) - - @override - def get_frame(self): - image = super().get_frame() + image = np.array(image) if not is_valid_image(image): return None return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 46648da2..65e6d832 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -24,7 +24,7 @@ if sys.platform == "linux": import pyscreeze - from PIL import features + from PIL import UnidentifiedImageError, features from capture_method.ScrotCaptureMethod import ScrotCaptureMethod from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod @@ -152,7 +152,7 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS[CaptureMethodEnum.XDISPLAY] = XDisplayCaptureMethod try: pyscreeze.screenshot() - except NotImplementedError: + except UnidentifiedImageError: pass else: # TODO: Investigate solution for Slow Scrot: @@ -202,7 +202,7 @@ def get_input_devices(): return cameras -def get_input_device_resolution(index: int): +def get_input_device_resolution(index: int) -> tuple[int, int] | None: if sys.platform != "win32": return (0, 0) filter_graph = FilterGraph() diff --git a/src/error_messages.py b/src/error_messages.py index 9d5df4fe..6cf64805 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -168,7 +168,7 @@ def linux_groups(): def linux_uinput(): set_text_message( "Failed to create a device file using `uinput` module. " - + "This can happen when runnign Linux under WSL. " + + "This can happen when running Linux under WSL. " + "Keyboard events have been disabled.", ) diff --git a/src/region_selection.py b/src/region_selection.py index b5c9dcb9..da6880f0 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -8,7 +8,6 @@ from cv2.typing import MatLike from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtTest import QTest -from pywinctl import getTopWindowAt from typing_extensions import override import error_messages @@ -34,6 +33,11 @@ if sys.platform == "linux": from Xlib.display import Display + # This variable may be missing in desktopless environment. x11 | wayland + os.environ.setdefault("XDG_SESSION_TYPE", "x11") + +# Must come after the linux XDG_SESSION_TYPE environment variable is set +from pywinctl import getTopWindowAt if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -113,7 +117,7 @@ def select_region(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() + hwnd = window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -162,7 +166,7 @@ def select_window(autosplit: "AutoSplit"): if not window: error_messages.region() return - hwnd = window.getHandle().id if sys.platform == "linux" else window.getHandle() + hwnd = window.getHandle() window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() @@ -179,7 +183,7 @@ def select_window(autosplit: "AutoSplit"): border_width = ceil((window_width - client_width) / 2) titlebar_with_border_height = window_height - client_height - border_width else: - data = window.getHandle().get_geometry()._data # noqa: SLF001 + data = window._xWin.get_geometry()._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 client_height = data["height"] client_width = data["width"] border_width = data["border_width"] diff --git a/src/utils.py b/src/utils.py index fae6c11d..ea7425e9 100644 --- a/src/utils.py +++ b/src/utils.py @@ -90,6 +90,8 @@ def first(iterable: Iterable[T]) -> T: def try_delete_dc(dc: "PyCDC"): + if sys.platform != "win32": + raise OSError try: dc.DeleteDC() except win32ui.error: From b24730e23e2f0b2cd85792cb374fd892523c02e8 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Dec 2023 22:14:17 -0500 Subject: [PATCH 08/28] Linux window recording and recovery complete --- README.md | 4 +-- scripts/install.ps1 | 2 +- ...sh => python_build_from_source_linux.bash} | 0 src/AutoSplit.py | 4 +-- src/capture_method/ScrotCaptureMethod.py | 15 +++++++++- ...ayCaptureMethod.py => XcbCaptureMethod.py} | 29 ++++++++++--------- src/capture_method/__init__.py | 17 +++++------ src/menu_bar.py | 8 ++--- src/region_selection.py | 19 ++++++++---- src/utils.py | 11 +++++-- 10 files changed, 67 insertions(+), 42 deletions(-) rename scripts/{linux_build_and_install_python.bash => python_build_from_source_linux.bash} (100%) rename src/capture_method/{XDisplayCaptureMethod.py => XcbCaptureMethod.py} (65%) diff --git a/README.md b/README.md index 695f63cd..603ce5fc 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ This program can be used to automatically start, split, and reset your preferred ##### Linux -- **XDisplay** (fast, requires xcb) - Uses X to take screenshots of the display. +- **X11 XCB** (fast, requires XCB) + Uses the XCB library to take screenshots of the X11 server. - **Scrot** (very slow, may leave files) Uses Scrot (SCReenshOT) to take screenshots. Leaves behind a screenshot file if interrupted. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7735670d..3f49f81a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -35,7 +35,7 @@ If ($IsLinux) { If (-not $Env:GITHUB_JOB -or $Env:GITHUB_JOB -eq 'Build') { sudo apt-get update # python3-tk for splash screen, npm for pyright, the rest for PySide6 - sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon + sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon0 } } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. diff --git a/scripts/linux_build_and_install_python.bash b/scripts/python_build_from_source_linux.bash similarity index 100% rename from scripts/linux_build_and_install_python.bash rename to scripts/python_build_from_source_linux.bash diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 90f1fa1e..3651b33b 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -42,9 +42,9 @@ AUTOSPLIT_VERSION, BGRA_CHANNEL_COUNT, FROZEN, - IS_WAYLAND, ONE_SECOND, QTIMER_FPS_LIMIT, + RUNNING_WAYLAND, auto_split_directory, decimal, flatten, @@ -985,7 +985,7 @@ def main(): error_messages.linux_groups() if KEYBOARD_UINPUT_ISSUE: error_messages.linux_uinput() - if IS_WAYLAND: + if RUNNING_WAYLAND: error_messages.linux_wayland() AutoSplit() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 21c54489..6368109a 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -6,8 +6,10 @@ import cv2 import numpy as np import pyscreeze +from pywinctl import getWindowsWithTitle from typing_extensions import override from Xlib.display import Display +from Xlib.error import BadWindow from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import is_valid_image @@ -27,7 +29,10 @@ def _read_action(self): return None xdisplay = Display() root = xdisplay.screen().root - data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + try: + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + except BadWindow: + return None offset_x = data["x"] offset_y = data["y"] selection = self._autosplit_ref.settings_dict["capture_region"] @@ -44,3 +49,11 @@ def _read_action(self): if not is_valid_image(image): return None return cv2.cvtColor(image, cv2.COLOR_RGB2BGRA) + + @override + def recover_window(self, captured_window_title: str): + windows = getWindowsWithTitle(captured_window_title) + if len(windows) == 0: + return False + self._autosplit_ref.hwnd = windows[0].getHandle() + return self.check_selected_region_exists() diff --git a/src/capture_method/XDisplayCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py similarity index 65% rename from src/capture_method/XDisplayCaptureMethod.py rename to src/capture_method/XcbCaptureMethod.py index 7a9aa121..41d4e040 100644 --- a/src/capture_method/XDisplayCaptureMethod.py +++ b/src/capture_method/XcbCaptureMethod.py @@ -6,17 +6,19 @@ import cv2 import numpy as np from PIL import ImageGrab +from pywinctl import getWindowsWithTitle from typing_extensions import override from Xlib.display import Display +from Xlib.error import BadWindow from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import is_valid_image -class XDisplayCaptureMethod(ThreadedLoopCaptureMethod): - name = "XDisplay" - short_description = "fast, requires xcb" - description = "\nUses XCB to take screenshots of the display" +class XcbCaptureMethod(ThreadedLoopCaptureMethod): + name = "X11 XCB" + short_description = "fast, requires XCB" + description = "\nUses the XCB library to take screenshots of the X11 server." _xdisplay: str | None = "" # ":0" @@ -26,7 +28,10 @@ def _read_action(self): return None xdisplay = Display() root = xdisplay.screen().root - data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + try: + data = root.translate_coords(self._autosplit_ref.hwnd, 0, 0)._data # noqa: SLF001 + except BadWindow: + return None offset_x = data["x"] offset_y = data["y"] # image = window.get_image(selection["x"], selection["y"], selection["width"], selection["height"], 1, 0) @@ -50,12 +55,8 @@ def _read_action(self): @override def recover_window(self, captured_window_title: str): - xdisplay = Display() - root = xdisplay.screen().root - children = root.query_tree().children - for window in children: - wm_class = window.get_wm_class() - if wm_class and wm_class[1] == captured_window_title: - self._autosplit_ref.hwnd = window.id - return self.check_selected_region_exists() - return False + windows = getWindowsWithTitle(captured_window_title) + if len(windows) == 0: + return False + self._autosplit_ref.hwnd = windows[0].getHandle() + return self.check_selected_region_exists() diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 65e6d832..ec9df778 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -27,7 +27,7 @@ from PIL import UnidentifiedImageError, features from capture_method.ScrotCaptureMethod import ScrotCaptureMethod - from capture_method.XDisplayCaptureMethod import XDisplayCaptureMethod + from capture_method.XcbCaptureMethod import XcbCaptureMethod if TYPE_CHECKING: @@ -85,7 +85,7 @@ def _generate_next_value_(name: "str | CaptureMethodEnum", *_): PRINTWINDOW_RENDERFULLCONTENT = auto() DESKTOP_DUPLICATION = auto() SCROT = auto() - XDISPLAY = auto() + XCB = auto() VIDEO_CAPTURE_DEVICE = auto() @@ -149,7 +149,7 @@ def get(self, key: CaptureMethodEnum, __default: object = None): CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod elif sys.platform == "linux": if features.check_feature(feature="xcb"): - CAPTURE_METHODS[CaptureMethodEnum.XDISPLAY] = XDisplayCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.XCB] = XcbCaptureMethod try: pyscreeze.screenshot() except UnidentifiedImageError: @@ -167,15 +167,14 @@ def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: initialize the new one with transfered subscriptions and update UI as needed. """ + subscriptions = autosplit.capture_method._subscriptions # pyright: ignore[reportPrivateUsage] # noqa: SLF001 autosplit.capture_method.close() autosplit.capture_method = CAPTURE_METHODS.get(selected_capture_method)(autosplit) + autosplit.capture_method._subscriptions = subscriptions # pyright: ignore[reportPrivateUsage] # noqa: SLF001 - if selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - autosplit.select_region_button.setDisabled(True) - autosplit.select_window_button.setDisabled(True) - else: - autosplit.select_region_button.setDisabled(False) - autosplit.select_window_button.setDisabled(False) + disable_selection_buttons = selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE + autosplit.select_region_button.setDisabled(disable_selection_buttons) + autosplit.select_window_button.setDisabled(disable_selection_buttons) @dataclass diff --git a/src/menu_bar.py b/src/menu_bar.py index 37dea594..9ee889ac 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -270,10 +270,10 @@ def __set_readme_link(self): lambda: webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#readme"), ) self.readme_link_button.setStyleSheet("border: 0px; background-color:rgba(0,0,0,0%);") - # TODO: Check if this is still necessary now that fusion theme is used on both - # if sys.platform == "linux": - # geometry = self.readme_link_button.geometry() - # self.readme_link_button.setGeometry(QtCore.QRect(51, 225, geometry.width(), geometry.height())) + if sys.platform == "linux": + geometry = self.readme_link_button.geometry() + self.readme_link_button.setText("#DOC#") # In-button font has different width so "README" doesn't fit -.- + self.readme_link_button.setGeometry(QtCore.QRect(116, 220, geometry.width(), geometry.height())) def __select_screenshot_directory(self): self._autosplit_ref.settings_dict["screenshot_directory"] = QFileDialog.getExistingDirectory( diff --git a/src/region_selection.py b/src/region_selection.py index da6880f0..217bf8ed 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -33,6 +33,7 @@ if sys.platform == "linux": from Xlib.display import Display + # This variable may be missing in desktopless environment. x11 | wayland os.environ.setdefault("XDG_SESSION_TYPE", "x11") @@ -68,6 +69,14 @@ ) +def get_top_window_at(x: int, y: int): + """Give QWidget time to disappear to avoid Xlib.error.BadDrawable on Linux.""" + if sys.platform == "linux": + # Tested in increments of 10ms on my Pop!_OS 22.04 VM + QTest.qWait(80) + return getTopWindowAt(x, y) + + # TODO: For later as a different picker option def __select_graphics_item(autosplit: "AutoSplit"): # pyright: ignore [reportUnusedFunction] """Uses the built-in GraphicsCapturePicker to select the Window.""" @@ -113,7 +122,7 @@ def select_region(autosplit: "AutoSplit"): QTest.qWait(1) del selector - window = getTopWindowAt(x, y) + window = get_top_window_at(x, y) if not window: error_messages.region() return @@ -133,9 +142,7 @@ def select_region(autosplit: "AutoSplit"): offset_x = window_x + left_bounds offset_y = window_y + top_bounds else: - xdisplay = Display() - root = xdisplay.screen().root - data = root.translate_coords(autosplit.hwnd, 0, 0)._data # noqa: SLF001 + data = window._xWin.translate_coords(autosplit.hwnd, 0, 0)._data # pyright:ignore[reportPrivateUsage] # noqa: SLF001 offset_x = data["x"] offset_y = data["y"] @@ -162,7 +169,7 @@ def select_window(autosplit: "AutoSplit"): QTest.qWait(1) del selector - window = getTopWindowAt(x, y) + window = get_top_window_at(x, y) if not window: error_messages.region() return @@ -351,7 +358,7 @@ def __init__(self): data["width"], data["height"], ) - self.setWindowTitle(" ") + self.setWindowTitle(type(self).__name__) self.setWindowOpacity(0.5) self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.show() diff --git a/src/utils.py b/src/utils.py index ea7425e9..2b84ba03 100644 --- a/src/utils.py +++ b/src/utils.py @@ -22,21 +22,27 @@ from winsdk.windows.ai.machinelearning import LearningModelDevice, LearningModelDeviceKind from winsdk.windows.media.capture import MediaCapture +RUNNING_WAYLAND: bool = False if sys.platform == "linux": import fcntl + from pyscreeze import ( + RUNNING_WAYLAND as RUNNING_WAYLAND, # pyright: ignore[reportConstantRedefinition, reportGeneralTypeIssues, reportUnknownVariableType] # noqa: PLC0414 + ) + + if TYPE_CHECKING: # Source does not exist, keep this under TYPE_CHECKING from _win32typing import PyCDC # pyright: ignore[reportMissingModuleSource] T = TypeVar("T") +DWMWA_EXTENDED_FRAME_BOUNDS = 9 +MAXBYTE = 255 ONE_SECOND = 1000 """1000 milliseconds in 1 second""" QTIMER_FPS_LIMIT = 1000 """QTimers are accurate to the millisecond""" -DWMWA_EXTENDED_FRAME_BOUNDS = 9 -MAXBYTE = 255 BGR_CHANNEL_COUNT = 3 """How many channels in a BGR image""" BGRA_CHANNEL_COUNT = 4 @@ -213,7 +219,6 @@ def flatten(nested_iterable: Iterable[Iterable[T]]) -> chain[T]: """Running from build made by PyInstaller""" auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) """The directory of either the AutoSplit executable or AutoSplit.py""" -IS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY", False)) # Shared strings # Check `excludeBuildNumber` during workflow dispatch build generate a clean version number From 00ecc590ea2b9412a8e7ace53f5ac1fd33bb8153 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 00:23:34 -0500 Subject: [PATCH 09/28] Update src/hotkeys.py --- src/hotkeys.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hotkeys.py b/src/hotkeys.py index 6c1676fc..e803e4db 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -13,7 +13,6 @@ import grp import os - # https://github.com/PyCQA/pylint/issues/7240 groups = {grp.getgrgid(group).gr_name for group in os.getgroups()} KEYBOARD_GROUPS_ISSUE = not {"input", "tty"}.issubset(groups) KEYBOARD_UINPUT_ISSUE = not try_input_device_access() From eb61a546141f8aaf32dd8d2e93eac065f34ccb88 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 16:34:12 -0500 Subject: [PATCH 10/28] Reload settings window when loading new profile --- docs/build instructions.md | 2 +- pyproject.toml | 1 + src/user_profile.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/build instructions.md b/docs/build instructions.md index 5bda1416..e74c7a9b 100644 --- a/docs/build instructions.md +++ b/docs/build instructions.md @@ -26,7 +26,7 @@ - `python3 -m venv .venv` - `source .venv/bin/activate` - Run `./scripts/install.ps1` to install all dependencies. - - If you're having issues with the PySide generated code, you might want to first run `pip uninstall -y shiboken6 PySide PySide-Essentials` + - If you're having issues with the PySide generated code, you might want to first run `pip uninstall -y shiboken6 PySide6 PySide6-Essentials` - Run the app directly with `./scripts/start.ps1 [--auto-controlled]`. - Or debug by pressing `F5` in VSCode. - The `--auto-controlled` flag is passed when AutoSplit is started by LiveSplit. diff --git a/pyproject.toml b/pyproject.toml index cd17952f..0b80a23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,7 @@ exclude = ".venv/*,src/gen/*" ignore = [ "E124", # Closing bracket may not match multi-line method invocation style (enforced by add-trailing-comma) "E70", # Allow ... on same line as def + "E721", # Breaks when needing an exact type # Autofixed by Ruff # Check for the "Fix" flag https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "E2", # Whitespace diff --git a/src/user_profile.py b/src/user_profile.py index 10d89f2b..40014fde 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -10,6 +10,7 @@ from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method from gen import design from hotkeys import HOTKEYS, remove_all_hotkeys, set_hotkey +from menu_bar import open_settings from utils import auto_split_directory if TYPE_CHECKING: @@ -120,6 +121,14 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s if load_settings_file_path.endswith(".pkl"): autosplit.show_error_signal.emit(error_messages.old_version_settings_file) return False + + # Allow seemlessly reloading the entire settings widget + settings_widget_was_open = False + settings_widget = cast(QtWidgets.QWidget | None, autosplit.SettingsWidget) + if settings_widget: + settings_widget_was_open = settings_widget.isVisible() + settings_widget.close() + try: with open(load_settings_file_path, encoding="utf-8") as file: # Casting here just so we can build an actual UserProfileDict once we're done validating @@ -156,6 +165,9 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s + "\nto automatically load Capture Region", ) + if settings_widget_was_open: + open_settings(autosplit) + return True From 62211078e243588a743a4ecd71a7c72692536d38 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 17:34:52 -0500 Subject: [PATCH 11/28] Update reload_start_image functions names --- src/AutoSplit.py | 39 +++++++++++++++++++++++++-------------- src/user_profile.py | 2 +- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 01119ff5..85830d2d 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -67,7 +67,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): screenshot_signal = QtCore.Signal() after_setting_hotkey_signal = QtCore.Signal() update_checker_widget_signal = QtCore.Signal(str, bool) - load_start_image_signal = QtCore.Signal(bool, bool) + reload_start_image_signal = QtCore.Signal(bool, bool) # Use this signal when trying to show an error from outside the main thread show_error_signal = QtCore.Signal(FunctionType) @@ -173,7 +173,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.previous_image_button.clicked.connect(lambda: self.undo_split(True)) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_start_image_button.clicked.connect(lambda: self.__load_start_image(True, True)) + self.reload_start_image_button.clicked.connect(lambda: self.__reload_start_image(True, True)) self.action_check_for_updates_on_open.changed.connect( lambda: user_profile.set_check_for_updates_on_open(self, self.action_check_for_updates_on_open.isChecked()), ) @@ -192,8 +192,7 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) return open_update_checker(self, latest_version, check_on_open) self.update_checker_widget_signal.connect(_update_checker_widget_signal_slot) - - self.load_start_image_signal.connect(self.__load_start_image) + self.reload_start_image_signal.connect(self.__reload_start_image) self.reset_signal.connect(self.reset) self.skip_split_signal.connect(self.skip_split) self.undo_split_signal.connect(self.undo_split) @@ -205,7 +204,7 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) self.timer_live_image.start(int(ONE_SECOND / self.settings_dict["fps_limit"])) # Automatic timer start - self.timer_start_image.timeout.connect(self.__start_image_function) + self.timer_start_image.timeout.connect(self.__compare_capture_for_auto_start) self.show() @@ -238,7 +237,7 @@ def __browse(self): # set the split image folder line to the directory text self.settings_dict["split_image_directory"] = new_split_image_directory self.split_image_folder_input.setText(f"{new_split_image_directory}/") - self.load_start_image_signal.emit(False, True) + self.reload_start_image_signal.emit(False, True) def __update_live_image_details(self, capture: MatLike | None, called_from_timer: bool = False): # HACK: Since this is also called in __get_capture_for_comparison, @@ -263,8 +262,21 @@ def __update_live_image_details(self, capture: MatLike | None, called_from_timer else: set_preview_image(self.live_image, capture) - def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): - """Not thread safe (if triggered by LiveSplit for example). Use `load_start_image_signal.emit` instead.""" + def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): + """ + Not thread safe (if triggered by LiveSplit for example). Use `reload_start_image_signal.emit` instead. + + 1. Stops the automated start check and clear the current Split Image. + 2. Reloads the Start Image from disk and validate. + 3. If validation passed: + - + - Updates the shown Split Image and Start Image text + - Reinitialise values + - Restart the automated start check + """ + if self.is_running: + raise RuntimeError("Start Image should never be reloaded whilst running!") + self.timer_start_image.stop() self.current_image_file_label.setText("-") self.start_image_status_value_label.setText("not found") @@ -280,8 +292,6 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo QApplication.processEvents() return - self.split_image_number = 0 - if not wait_for_delay and self.start_image.get_pause_time(self) > 0: self.start_image_status_value_label.setText("paused") self.table_current_image_highest_label.setText("-") @@ -290,6 +300,7 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo self.start_image_status_value_label.setText("ready") self.__update_split_image(self.start_image) + self.split_image_number = 0 self.highest_similarity = 0.0 self.reset_highest_similarity = 0.0 self.split_below_threshold = False @@ -297,9 +308,9 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo QApplication.processEvents() - def __start_image_function(self): + def __compare_capture_for_auto_start(self): if not self.start_image: - return + raise ValueError("There are no Start Image. How did we even get here?") self.start_image_status_value_label.setText("ready") self.__update_split_image(self.start_image) @@ -524,7 +535,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 self.run_start_time = time() if not (validate_before_parsing(self) and parse_and_validate_images(self)): - # `safe_to_reload_start_image: bool = False` because __load_start_image also does this check, + # `safe_to_reload_start_image: bool = False` because __reload_start_image also does this check, # we don't want to double a Start/Reset Image error message self.gui_changes_on_reset(False) return @@ -783,7 +794,7 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): QApplication.processEvents() if safe_to_reload_start_image: - self.load_start_image_signal.emit(False, False) + self.reload_start_image_signal.emit(False, False) def __get_capture_for_comparison(self): """Grab capture region and resize for comparison.""" diff --git a/src/user_profile.py b/src/user_profile.py index 40014fde..14485121 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -187,7 +187,7 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path # TODO: Should this check be in `__load_start_image` ? if not autosplit.is_running: - autosplit.load_start_image_signal.emit(False, True) + autosplit.reload_start_image_signal.emit(False, True) def load_settings_on_open(autosplit: "AutoSplit"): From c33579dc30072daa24ab874d58e6416c6e511bcb Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 17:41:10 -0500 Subject: [PATCH 12/28] Live Preview, Image Start and Window Recovery now use event subscription instead of relying on a timer. --- src/AutoSplit.py | 69 +++---------------- src/capture_method/BitBltCaptureMethod.py | 7 ++ src/capture_method/CaptureMethodBase.py | 29 ++++++-- .../VideoCaptureDeviceCaptureMethod.py | 1 + src/menu_bar.py | 7 +- src/user_profile.py | 10 +++ 6 files changed, 57 insertions(+), 66 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 85830d2d..214024b8 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -71,12 +71,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # Use this signal when trying to show an error from outside the main thread show_error_signal = QtCore.Signal(FunctionType) - # Timers - timer_live_image = QtCore.QTimer() - timer_live_image.setTimerType(QtCore.Qt.TimerType.PreciseTimer) - timer_start_image = QtCore.QTimer() - timer_start_image.setTimerType(QtCore.Qt.TimerType.PreciseTimer) - # Widgets AboutWidget: about.Ui_AboutAutoSplitWidget | None = None UpdateCheckerWidget: update_checker.Ui_UpdateChecker | None = None @@ -198,13 +192,8 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) self.undo_split_signal.connect(self.undo_split) self.pause_signal.connect(self.pause) self.screenshot_signal.connect(self.__take_screenshot) - - # live image checkbox - self.timer_live_image.timeout.connect(lambda: self.__update_live_image_details(None, True)) - self.timer_live_image.start(int(ONE_SECOND / self.settings_dict["fps_limit"])) - - # Automatic timer start - self.timer_start_image.timeout.connect(self.__compare_capture_for_auto_start) + # Before loading settings since the default value of "Live Capture Region" is True + self.capture_method.subscribe_to_new_frame(self.update_live_image_details) self.show() @@ -239,14 +228,7 @@ def __browse(self): self.split_image_folder_input.setText(f"{new_split_image_directory}/") self.reload_start_image_signal.emit(False, True) - def __update_live_image_details(self, capture: MatLike | None, called_from_timer: bool = False): - # HACK: Since this is also called in __get_capture_for_comparison, - # we don't need to update anything if the app is running - if called_from_timer: - if self.is_running or self.start_image: - return - capture = self.capture_method.last_captured_image - + def update_live_image_details(self, capture: MatLike | None): # Update title from target window or Capture Device name capture_region_window_label = ( self.settings_dict["capture_device_name"] @@ -255,12 +237,7 @@ def __update_live_image_details(self, capture: MatLike | None, called_from_timer ) self.capture_region_window_label.setText(capture_region_window_label) - # Simply clear if "live capture region" setting is off - if not (self.settings_dict["live_capture_region"] and capture_region_window_label): - self.live_image.clear() - # Set live image in UI - else: - set_preview_image(self.live_image, capture) + set_preview_image(self.live_image, capture) def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): """ @@ -277,7 +254,7 @@ def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: if self.is_running: raise RuntimeError("Start Image should never be reloaded whilst running!") - self.timer_start_image.stop() + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.current_image_file_label.setText("-") self.start_image_status_value_label.setText("not found") set_preview_image(self.current_split_image, None) @@ -304,18 +281,17 @@ def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: self.highest_similarity = 0.0 self.reset_highest_similarity = 0.0 self.split_below_threshold = False - self.timer_start_image.start(int(ONE_SECOND / self.settings_dict["fps_limit"])) + self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) QApplication.processEvents() - def __compare_capture_for_auto_start(self): + def __compare_capture_for_auto_start(self, capture: MatLike | None): if not self.start_image: raise ValueError("There are no Start Image. How did we even get here?") self.start_image_status_value_label.setText("ready") self.__update_split_image(self.start_image) - capture = self.__get_capture_for_comparison() start_image_threshold = self.start_image.get_similarity_threshold(self) start_image_similarity = self.start_image.compare_with_capture(self, capture) @@ -341,7 +317,7 @@ def __compare_capture_for_auto_start(self): (below_flag and self.split_below_threshold and similarity_diff < 0 and is_valid_image(capture)) # noqa: PLR0916 # See above TODO or (not below_flag and similarity_diff >= 0) ): - self.timer_start_image.stop() + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.split_below_threshold = False if not self.start_image.check_flag(DUMMY_FLAG): @@ -654,7 +630,7 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ start = time() while True: - capture = self.__get_capture_for_comparison() + capture = self.capture_method.last_captured_image if self.__reset_if_should(capture): return True @@ -723,7 +699,7 @@ def __pause_loop(self, stop_time: float, message: str): pause_split_image_number = self.split_image_number while True: # Calculate similarity for Reset Image - if self.__reset_if_should(self.__get_capture_for_comparison()): + if self.__reset_if_should(self.capture_method.last_captured_image): return True time_delta = time() - start_time @@ -743,7 +719,7 @@ def __pause_loop(self, stop_time: float, message: str): return False def gui_changes_on_start(self): - self.timer_start_image.stop() + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.start_auto_splitter_button.setText("Running...") self.split_image_folder_button.setEnabled(False) self.reload_start_image_button.setEnabled(False) @@ -796,29 +772,6 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): if safe_to_reload_start_image: self.reload_start_image_signal.emit(False, False) - def __get_capture_for_comparison(self): - """Grab capture region and resize for comparison.""" - capture = self.capture_method.last_captured_image - - # This most likely means we lost capture - # (ie the captured window was closed, crashed, lost capture device, etc.) - if not is_valid_image(capture): - # Try to recover by using the window name - if self.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - self.live_image.setText("Waiting for capture device...") - else: - message = "Trying to recover window..." - if self.settings_dict["capture_method"] == CaptureMethodEnum.BITBLT: - message += "\n(captured window may be incompatible with BitBlt)" - self.live_image.setText(message) - _recovered = self.capture_method.recover_window(self.settings_dict["captured_window_title"]) - # TODO: Gotta wait next loop now - # if recovered: - # capture = self.capture_method.last_captured_image - - self.__update_live_image_details(capture) - return capture - def __reset_if_should(self, capture: MatLike | None): """Checks if we should reset, resets if it's the case, and returns the result.""" if self.reset_image: diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 99fd5bb0..2bc97b36 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -32,6 +32,13 @@ class BitBltCaptureMethod(ThreadedLoopCaptureMethod): + "\nThe smaller the selected region, the more efficient it is. " ) + @property + @override + def window_recovery_message(self): + if type(self) is BitBltCaptureMethod: + return super().window_recovery_message + "\n(captured window may be incompatible with BitBlt)" + return super().window_recovery_message + _render_full_content = False @override diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index bb4177fb..c74729f5 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -1,13 +1,13 @@ from abc import ABCMeta, abstractmethod from collections.abc import Callable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, final from cv2.typing import MatLike from PySide6 import QtCore from typing_extensions import override import error_messages -from utils import ONE_SECOND, QTIMER_FPS_LIMIT, is_valid_hwnd +from utils import ONE_SECOND, QTIMER_FPS_LIMIT, is_valid_hwnd, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -17,10 +17,11 @@ class CaptureMethodBase: name = "None" short_description = "" description = "" + window_recovery_message = "Trying to recover window..." last_captured_image: MatLike | None = None _autosplit_ref: "AutoSplit" - _subscriptions: list[Callable[[MatLike | None], object]] = [] # FIXME: # noqa: RUF012 + _subscriptions: set[Callable[[MatLike | None], object]] = set() # FIXME: # noqa: RUF012 def __init__(self, autosplit: "AutoSplit"): self._autosplit_ref = autosplit @@ -38,21 +39,35 @@ def set_fps_limit(self, fps: int): pass def recover_window(self, captured_window_title: str) -> bool: # noqa: PLR6301 + # Some capture methods can't "recover" and must simply wait return False def check_selected_region_exists(self) -> bool: return is_valid_hwnd(self._autosplit_ref.hwnd) def subscribe_to_new_frame(self, callback: Callable[[MatLike | None], object]): - self._subscriptions.append(callback) + self._subscriptions.add(callback) def unsubscribe_from_new_frame(self, callback: Callable[[MatLike | None], object]): - self._subscriptions.remove(callback) + try: + self._subscriptions.remove(callback) + except KeyError: + pass + @final def _push_new_frame_to_subscribers(self, frame: MatLike | None): for subscription in self._subscriptions: subscription(frame) + # This most likely means we lost capture + # (ie the captured window was closed, crashed, lost capture device, etc.) + if not is_valid_image(frame): + # Try to recover by using the window name + self._autosplit_ref.live_image.setText(self.window_recovery_message) + self._autosplit_ref.capture_method.recover_window( + self._autosplit_ref.settings_dict["captured_window_title"], + ) + class ThreadedLoopCaptureMethod(CaptureMethodBase, metaclass=ABCMeta): def __init__(self, autosplit: "AutoSplit"): @@ -80,7 +95,11 @@ def _read_action(self) -> MatLike | None: """The synchronous code that requests a new image from the operating system.""" raise NotImplementedError + @final def __read_loop(self): + if len(self._subscriptions) == 0: + # optimisation on idle: no subscriber means no work needed + return try: if self.check_selected_region_exists(): captured_image = self._read_action() diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 987f6373..d194ef1f 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -36,6 +36,7 @@ class VideoCaptureDeviceCaptureMethod(ThreadedLoopCaptureMethod): "\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. " + "\nYou can select one below. " ) + window_recovery_message = "Waiting for capture device..." capture_device: cv2.VideoCapture diff --git a/src/menu_bar.py b/src/menu_bar.py index 6eb99fa7..ed18faa0 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -23,7 +23,7 @@ ) from gen import about, design, settings as settings_ui, update_checker from hotkeys import HOTKEYS, Hotkey, set_hotkey -from utils import AUTOSPLIT_VERSION, GITHUB_REPOSITORY, ONE_SECOND, decimal, fire_and_forget +from utils import AUTOSPLIT_VERSION, GITHUB_REPOSITORY, decimal, fire_and_forget if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -233,7 +233,6 @@ def __capture_device_changed(self): def __fps_limit_changed(self, value: int): value = self.fps_limit_spinbox.value() self._autosplit_ref.settings_dict["fps_limit"] = value - self._autosplit_ref.timer_live_image.setInterval(int(ONE_SECOND / value)) self._autosplit_ref.capture_method.set_fps_limit(value) @fire_and_forget @@ -319,7 +318,9 @@ def hotkey_connect(hotkey: Hotkey): # Capture Settings self.fps_limit_spinbox.valueChanged.connect(self.__fps_limit_changed) self.live_capture_region_checkbox.stateChanged.connect( - lambda: self.__set_value("live_capture_region", self.live_capture_region_checkbox.isChecked()), + lambda: user_profile.update_live_capture_region_setting( + self._autosplit_ref, self.live_capture_region_checkbox.isChecked(), + ), ) self.capture_method_combobox.currentIndexChanged.connect( lambda: self.__set_value("capture_method", self.__capture_method_changed()), diff --git a/src/user_profile.py b/src/user_profile.py index 14485121..9edcd617 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -241,3 +241,13 @@ def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bo "check_for_updates_on_open", value, ) + + +def update_live_capture_region_setting(autosplit: "AutoSplit", value: bool): + autosplit.settings_dict["live_capture_region"] = value + if value: + autosplit.capture_method.subscribe_to_new_frame(autosplit.update_live_image_details) + else: + autosplit.update_live_image_details(None) + autosplit.live_image.setText("Live Capture Region Hidden") + autosplit.capture_method.unsubscribe_from_new_frame(autosplit.update_live_image_details) From 8872e031cc625d702d15eeb924600651179159bc Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 18:10:49 -0500 Subject: [PATCH 13/28] Merge `__check_for_reset_state_update_ui` function and update comment --- src/AutoSplit.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 214024b8..aeae5ba5 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -494,13 +494,6 @@ def start_auto_splitter(self): self.start_auto_splitter_signal.emit() - def __check_for_reset_state_update_ui(self): - """Check if AutoSplit is started, if not then update the GUI.""" - if not self.is_running: - self.gui_changes_on_reset(True) - return True - return False - def __auto_splitter(self): # noqa: PLR0912,PLR0915 if not self.settings_dict["split_hotkey"] and not self.is_auto_controlled: self.gui_changes_on_reset(True) @@ -805,7 +798,11 @@ def __reset_if_should(self, capture: MatLike | None): self.table_reset_image_threshold_label.setText("N/A") self.table_reset_image_highest_label.setText("N/A") - return self.__check_for_reset_state_update_ui() + # Check if AutoSplit is still running after the above reset check, if not then update the GUI. + if not self.is_running: + self.gui_changes_on_reset(True) + return True + return False def __update_split_image(self, specific_image: AutoSplitImage | None = None): # Start image is expected to be out of range (index 0 of 0-length array) From 011404a15095a97ea475899c5ef93f2435e66b20 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 20:39:07 -0500 Subject: [PATCH 14/28] Address hotkey disabling TODO --- src/AutoSplit.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index aeae5ba5..0646523c 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -719,12 +719,6 @@ def gui_changes_on_start(self): self.previous_image_button.setEnabled(True) self.next_image_button.setEnabled(True) - # TODO: Do we actually need to disable setting new hotkeys once started? - # What does this achieve? (See below TODO) - if self.SettingsWidget: - for hotkey in HOTKEYS: - getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) - if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) self.reset_button.setEnabled(True) @@ -749,12 +743,6 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): self.previous_image_button.setEnabled(False) self.next_image_button.setEnabled(False) - # TODO: Do we actually need to disable setting new hotkeys once started? - # What does this achieve? (see above TODO) - if self.SettingsWidget and not self.is_auto_controlled: - for hotkey in HOTKEYS: - getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) - if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(True) self.reset_button.setEnabled(False) From 88acd81db5f6b12f64f653071cd2164bb4357c2b Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 20:43:09 -0500 Subject: [PATCH 15/28] Async reset image that also respects its own pause time --- README.md | 6 -- res/design.ui | 4 +- res/settings.ui | 20 ----- src/AutoSplit.py | 165 ++++++++++++++++++++++-------------------- src/error_messages.py | 4 - src/hotkeys.py | 7 -- src/menu_bar.py | 7 +- src/user_profile.py | 4 +- 8 files changed, 91 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 6fd136f2..b63df63c 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,6 @@ If this option is enabled, when the last split meets the threshold and splits, A If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. -#### Start also Resets - -If this option is enabled, a "Start" command (ie: from the Start Image) will also send the "Reset" command. This is useful if you want to automatically restart your timer using the Start Image. Since AutoSplit won't be running and won't be checking for the Reset Image. - -Having the reset image check be active at all time would be a better, more organic solution in the future. But that is dependent on migrating to an observer pattern () and being able to reload all images. - #### Enable auto Reset Image This option is mainly meant to be toggled with the `Toggle auto Reset Image` hotkey. You can enable it to temporarily disable the Reset Image if you make a mistake in your run that would cause the Reset Image to trigger. Like exiting back to the game's menu (aka Save&Quit). diff --git a/res/design.ui b/res/design.ui index 4f339507..80d7fda1 100644 --- a/res/design.ui +++ b/res/design.ui @@ -796,7 +796,7 @@ - + 457 @@ -809,7 +809,7 @@ Qt::NoFocus - Reload Start Image + Reload Images diff --git a/res/settings.ui b/res/settings.ui index 1238e382..bcf28cb7 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -480,25 +480,6 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be Default Delay Time (ms): - - - - 10 - 150 - 261 - 24 - - - - Start also Resets - - - false - - - false - - custom_image_settings_info_label default_delay_time_spinbox enable_auto_reset_image_checkbox @@ -511,7 +492,6 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be default_pause_time_label default_delay_time_label readme_link_button - start_also_resets_checkbox diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 0646523c..78a58aeb 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -20,7 +20,7 @@ import error_messages import user_profile from AutoControlledThread import AutoControlledThread -from AutoSplitImage import START_KEYWORD, AutoSplitImage, ImageType +from AutoSplitImage import AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker from hotkeys import HOTKEYS, after_setting_hotkey, send_command @@ -67,7 +67,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): screenshot_signal = QtCore.Signal() after_setting_hotkey_signal = QtCore.Signal() update_checker_widget_signal = QtCore.Signal(str, bool) - reload_start_image_signal = QtCore.Signal(bool, bool) + reload_images_signal = QtCore.Signal(bool, bool) # Use this signal when trying to show an error from outside the main thread show_error_signal = QtCore.Signal(FunctionType) @@ -102,6 +102,7 @@ def __init__(self): # noqa: PLR0915 self.waiting_for_split_delay = False self.split_below_threshold = False self.run_start_time = 0.0 + self.last_reset_time = 0.0 self.start_image: AutoSplitImage | None = None self.reset_image: AutoSplitImage | None = None self.split_images: list[AutoSplitImage] = [] @@ -167,7 +168,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.previous_image_button.clicked.connect(lambda: self.undo_split(True)) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_start_image_button.clicked.connect(lambda: self.__reload_start_image(True, True)) + self.reload_images_button.clicked.connect(lambda: self.__reload_images(True, True)) self.action_check_for_updates_on_open.changed.connect( lambda: user_profile.set_check_for_updates_on_open(self, self.action_check_for_updates_on_open.isChecked()), ) @@ -186,7 +187,7 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) return open_update_checker(self, latest_version, check_on_open) self.update_checker_widget_signal.connect(_update_checker_widget_signal_slot) - self.reload_start_image_signal.connect(self.__reload_start_image) + self.reload_images_signal.connect(self.__reload_images) self.reset_signal.connect(self.reset) self.skip_split_signal.connect(self.skip_split) self.undo_split_signal.connect(self.undo_split) @@ -226,7 +227,7 @@ def __browse(self): # set the split image folder line to the directory text self.settings_dict["split_image_directory"] = new_split_image_directory self.split_image_folder_input.setText(f"{new_split_image_directory}/") - self.reload_start_image_signal.emit(False, True) + self.reload_images_signal.emit(False, True) def update_live_image_details(self, capture: MatLike | None): # Update title from target window or Capture Device name @@ -239,49 +240,54 @@ def update_live_image_details(self, capture: MatLike | None): set_preview_image(self.live_image, capture) - def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): + def __reload_images(self, started_by_button: bool = False, wait_for_delay: bool = True): """ - Not thread safe (if triggered by LiveSplit for example). Use `reload_start_image_signal.emit` instead. + Not thread safe (if triggered by LiveSplit for example). Use `reload_images_signal.emit` instead. - 1. Stops the automated start check and clear the current Split Image. - 2. Reloads the Start Image from disk and validate. + 1. Stops the automated comparison checks and clear the current Split Image. + 2. Re-initializes all values affected by the automated checks. Assume we may get completely different images. + Or even no image where there was one before. 3. If validation passed: - - - - Updates the shown Split Image and Start Image text - - Reinitialise values - - Restart the automated start check + - - Updates the shown Split Image, Start Image text and Reset Image text. + - Restarts the automated checks. """ if self.is_running: - raise RuntimeError("Start Image should never be reloaded whilst running!") + raise RuntimeError("Images should never be reloaded whilst running!") + # Stop all async comparisons self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_reset) + + # Reset values that can be edited by __compare_capture_for_auto_start or __compare_capture_for_auto_reset + self.split_image_number = 0 + self.highest_similarity = 0.0 + self.reset_highest_similarity = 0.0 + self.split_below_threshold = False self.current_image_file_label.setText("-") self.start_image_status_value_label.setText("not found") + self.table_reset_image_live_label.setText("N/A") + self.table_reset_image_threshold_label.setText("N/A") + self.table_reset_image_highest_label.setText("N/A") set_preview_image(self.current_split_image, None) - if not (validate_before_parsing(self, started_by_button) and parse_and_validate_images(self)): - QApplication.processEvents() - return - - if not self.start_image: - if started_by_button: - error_messages.no_keyword_image(START_KEYWORD) - QApplication.processEvents() - return + if validate_before_parsing(self, started_by_button): + parse_and_validate_images(self) - if not wait_for_delay and self.start_image.get_pause_time(self) > 0: - self.start_image_status_value_label.setText("paused") - self.table_current_image_highest_label.setText("-") - self.table_current_image_threshold_label.setText("-") - else: - self.start_image_status_value_label.setText("ready") - self.__update_split_image(self.start_image) + if self.start_image: + if not wait_for_delay and self.start_image.get_pause_time(self) > 0: + self.start_image_status_value_label.setText("paused") + self.table_current_image_highest_label.setText("-") + self.table_current_image_threshold_label.setText("-") + else: + self.start_image_status_value_label.setText("ready") + self.__update_split_image(self.start_image) + self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) - self.split_image_number = 0 - self.highest_similarity = 0.0 - self.reset_highest_similarity = 0.0 - self.split_below_threshold = False - self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) + if self.reset_image: + self.table_reset_image_live_label.setText("-") + self.table_reset_image_highest_label.setText("-") + self.table_reset_image_threshold_label.setText(decimal(self.reset_image.get_similarity_threshold(self))) + self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_reset) QApplication.processEvents() @@ -477,6 +483,8 @@ def reset(self): When the reset button or hotkey is pressed, it will set `is_running` to False, which will trigger in the __auto_splitter function, if running, to abort and change GUI. """ + self.last_reset_time = time() + self.reset_highest_similarity = 0.0 self.is_running = False # Functions for the hotkeys to return to the main thread from signals and start their corresponding functions @@ -504,7 +512,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 self.run_start_time = time() if not (validate_before_parsing(self) and parse_and_validate_images(self)): - # `safe_to_reload_start_image: bool = False` because __reload_start_image also does this check, + # `safe_to_reload_start_image: bool = False` because __reload_start_and_reset_images also does this check, # we don't want to double a Start/Reset Image error message self.gui_changes_on_reset(False) return @@ -608,7 +616,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 return # loop breaks to here when the last image splits - self.is_running = False + self.reset() self.gui_changes_on_reset(True) def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_array: list[bool]): @@ -625,7 +633,7 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ while True: capture = self.capture_method.last_captured_image - if self.__reset_if_should(capture): + if self.__reset_gui_if_not_running(): return True similarity = self.split_image.compare_with_capture(self, capture) @@ -692,7 +700,7 @@ def __pause_loop(self, stop_time: float, message: str): pause_split_image_number = self.split_image_number while True: # Calculate similarity for Reset Image - if self.__reset_if_should(self.capture_method.last_captured_image): + if self.__reset_gui_if_not_running(): return True time_delta = time() - start_time @@ -715,7 +723,7 @@ def gui_changes_on_start(self): self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.start_auto_splitter_button.setText("Running...") self.split_image_folder_button.setEnabled(False) - self.reload_start_image_button.setEnabled(False) + self.reload_images_button.setEnabled(False) self.previous_image_button.setEnabled(True) self.next_image_button.setEnabled(True) @@ -735,11 +743,8 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): self.table_current_image_live_label.setText("-") self.table_current_image_highest_label.setText("-") self.table_current_image_threshold_label.setText("-") - self.table_reset_image_live_label.setText("-") - self.table_reset_image_highest_label.setText("-") - self.table_reset_image_threshold_label.setText("-") self.split_image_folder_button.setEnabled(True) - self.reload_start_image_button.setEnabled(True) + self.reload_images_button.setEnabled(True) self.previous_image_button.setEnabled(False) self.next_image_button.setEnabled(False) @@ -751,42 +756,46 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): QApplication.processEvents() if safe_to_reload_start_image: - self.reload_start_image_signal.emit(False, False) + self.reload_images_signal.emit(False, False) + + def __is_reset_image_is_paused(self): + if not self.reset_image: + raise ValueError("There are no Reset Image. How did we even get here?") + + current_time = time() + # Check if Reset Image is paused because we recently resetted + paused = current_time - self.last_reset_time <= self.reset_image.get_pause_time(self) + # Check if Reset Image is paused because we are too close to just starting + # because the Reset Image being the same as the Start Image is a common use case. + if not paused and self.start_image: + paused = current_time - self.run_start_time <= self.start_image.get_pause_time(self) + return paused + + def __compare_capture_for_auto_reset(self, capture: MatLike | None): + if not self.reset_image: + raise ValueError("There are no Reset Image. How did we even get here?") + + if not self.settings_dict["enable_auto_reset"]: + self.table_reset_image_live_label.setText("disabled") + return - def __reset_if_should(self, capture: MatLike | None): - """Checks if we should reset, resets if it's the case, and returns the result.""" - if self.reset_image: - if self.settings_dict["enable_auto_reset"]: - similarity = self.reset_image.compare_with_capture(self, capture) - threshold = self.reset_image.get_similarity_threshold(self) - - pause_times = [self.reset_image.get_pause_time(self)] - if self.start_image: - pause_times.append(self.start_image.get_pause_time(self)) - paused = time() - self.run_start_time <= max(pause_times) - if paused: - should_reset = False - self.table_reset_image_live_label.setText("paused") - else: - should_reset = similarity >= threshold - if similarity > self.reset_highest_similarity: - self.reset_highest_similarity = similarity - self.table_reset_image_highest_label.setText(decimal(self.reset_highest_similarity)) - self.table_reset_image_live_label.setText(decimal(similarity)) - - self.table_reset_image_threshold_label.setText(decimal(threshold)) - - if should_reset: - send_command(self, "reset") - self.reset() - else: - self.table_reset_image_live_label.setText("disabled") - else: - self.table_reset_image_live_label.setText("N/A") - self.table_reset_image_threshold_label.setText("N/A") - self.table_reset_image_highest_label.setText("N/A") + if self.__is_reset_image_is_paused(): + self.table_reset_image_live_label.setText("paused") + return + + similarity = self.reset_image.compare_with_capture(self, capture) + threshold = self.reset_image.get_similarity_threshold(self) + if similarity > self.reset_highest_similarity: + self.reset_highest_similarity = similarity + self.table_reset_image_highest_label.setText(decimal(self.reset_highest_similarity)) + self.table_reset_image_live_label.setText(decimal(similarity)) + + if similarity >= threshold: + send_command(self, "reset") + self.reset() - # Check if AutoSplit is still running after the above reset check, if not then update the GUI. + def __reset_gui_if_not_running(self): + """Checks if we are in a "not running" state, update GUI if it's the case, and returns the result.""" if not self.is_running: self.gui_changes_on_reset(True) return True diff --git a/src/error_messages.py b/src/error_messages.py index c455f265..b9007ae3 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -86,10 +86,6 @@ def alignment_not_matched(): set_text_message("No area in capture region matched reference image. Alignment failed.") -def no_keyword_image(keyword: str): - set_text_message(f"Your split image folder does not contain an image with the keyword {keyword!r}.") - - def multiple_keyword_images(keyword: str): set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") diff --git a/src/hotkeys.py b/src/hotkeys.py index 34f4519e..b33058ef 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -48,16 +48,9 @@ def after_setting_hotkey(autosplit: "AutoSplit"): def send_command(autosplit: "AutoSplit", command: Commands): - # Note: Rather than having the start image able to also reset the timer, - # having the reset image check be active at all time would be a better, more organic solution, - # but that is dependent on migrating to an observer pattern (#219) and being able to reload all images. match command: case _ if autosplit.is_auto_controlled: - if command == "start" and autosplit.settings_dict["start_also_resets"]: - print("reset", flush=True) print(command, flush=True) - case "start" if autosplit.settings_dict["start_also_resets"]: - _send_hotkey(autosplit.settings_dict["reset_hotkey"]) case "reset": _send_hotkey(autosplit.settings_dict["reset_hotkey"]) case "start" | "split": diff --git a/src/menu_bar.py b/src/menu_bar.py index ed18faa0..20d51f16 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -184,7 +184,7 @@ def __update_default_threshold(self, value: Any): self._autosplit_ref.table_reset_image_threshold_label.setText( decimal(self._autosplit_ref.reset_image.get_similarity_threshold(self._autosplit_ref)) if self._autosplit_ref.reset_image - else "-", + else "N/A", ) def __set_value(self, key: str, value: Any): @@ -311,7 +311,6 @@ def hotkey_connect(hotkey: Hotkey): self.default_delay_time_spinbox.setValue(self._autosplit_ref.settings_dict["default_delay_time"]) self.default_pause_time_spinbox.setValue(self._autosplit_ref.settings_dict["default_pause_time"]) self.loop_splits_checkbox.setChecked(self._autosplit_ref.settings_dict["loop_splits"]) - self.start_also_resets_checkbox.setChecked(self._autosplit_ref.settings_dict["start_also_resets"]) self.enable_auto_reset_image_checkbox.setChecked(self._autosplit_ref.settings_dict["enable_auto_reset"]) # endregion # region Binding @@ -350,9 +349,6 @@ def hotkey_connect(hotkey: Hotkey): self.loop_splits_checkbox.stateChanged.connect( lambda: self.__set_value("loop_splits", self.loop_splits_checkbox.isChecked()), ) - self.start_also_resets_checkbox.stateChanged.connect( - lambda: self.__set_value("start_also_resets", self.start_also_resets_checkbox.isChecked()), - ) self.enable_auto_reset_image_checkbox.stateChanged.connect( lambda: self.__set_value("enable_auto_reset", self.enable_auto_reset_image_checkbox.isChecked()), ) @@ -388,7 +384,6 @@ def get_default_settings_from_ui(autosplit: "AutoSplit"): "default_delay_time": default_settings_dialog.default_delay_time_spinbox.value(), "default_pause_time": default_settings_dialog.default_pause_time_spinbox.value(), "loop_splits": default_settings_dialog.loop_splits_checkbox.isChecked(), - "start_also_resets": default_settings_dialog.start_also_resets_checkbox.isChecked(), "enable_auto_reset": default_settings_dialog.enable_auto_reset_image_checkbox.isChecked(), "split_image_directory": autosplit.split_image_folder_input.text(), "screenshot_directory": default_settings_dialog.screenshot_directory_input.text(), diff --git a/src/user_profile.py b/src/user_profile.py index 9edcd617..39efebc5 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -35,7 +35,6 @@ class UserProfileDict(TypedDict): default_delay_time: int default_pause_time: float loop_splits: bool - start_also_resets: bool enable_auto_reset: bool split_image_directory: str screenshot_directory: str @@ -67,7 +66,6 @@ def copy(): default_delay_time=0, default_pause_time=10, loop_splits=False, - start_also_resets=False, enable_auto_reset=True, split_image_directory="", screenshot_directory="", @@ -187,7 +185,7 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path # TODO: Should this check be in `__load_start_image` ? if not autosplit.is_running: - autosplit.reload_start_image_signal.emit(False, True) + autosplit.reload_images_signal.emit(False, True) def load_settings_on_open(autosplit: "AutoSplit"): From 9f35b8130842ee0f068a1c76e6a45ea0a3a4e6d8 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 19 Dec 2023 23:11:08 -0500 Subject: [PATCH 16/28] unspecific fixes --- src/AutoSplit.py | 29 ++++++++++++++----------- src/capture_method/CaptureMethodBase.py | 25 +++++++++++---------- src/capture_method/__init__.py | 2 ++ src/user_profile.py | 11 ++-------- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 78a58aeb..43e48a37 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -67,7 +67,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): screenshot_signal = QtCore.Signal() after_setting_hotkey_signal = QtCore.Signal() update_checker_widget_signal = QtCore.Signal(str, bool) - reload_images_signal = QtCore.Signal(bool, bool) + reload_images_signal = QtCore.Signal(bool) # Use this signal when trying to show an error from outside the main thread show_error_signal = QtCore.Signal(FunctionType) @@ -168,7 +168,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.previous_image_button.clicked.connect(lambda: self.undo_split(True)) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_images_button.clicked.connect(lambda: self.__reload_images(True, True)) + self.reload_images_button.clicked.connect(lambda: self.__reload_images(True)) self.action_check_for_updates_on_open.changed.connect( lambda: user_profile.set_check_for_updates_on_open(self, self.action_check_for_updates_on_open.isChecked()), ) @@ -227,7 +227,7 @@ def __browse(self): # set the split image folder line to the directory text self.settings_dict["split_image_directory"] = new_split_image_directory self.split_image_folder_input.setText(f"{new_split_image_directory}/") - self.reload_images_signal.emit(False, True) + self.reload_images_signal.emit(False) def update_live_image_details(self, capture: MatLike | None): # Update title from target window or Capture Device name @@ -240,7 +240,7 @@ def update_live_image_details(self, capture: MatLike | None): set_preview_image(self.live_image, capture) - def __reload_images(self, started_by_button: bool = False, wait_for_delay: bool = True): + def __reload_images(self, started_by_button: bool = False): """ Not thread safe (if triggered by LiveSplit for example). Use `reload_images_signal.emit` instead. @@ -259,12 +259,17 @@ def __reload_images(self, started_by_button: bool = False, wait_for_delay: bool self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_reset) # Reset values that can be edited by __compare_capture_for_auto_start or __compare_capture_for_auto_reset + # Start related self.split_image_number = 0 self.highest_similarity = 0.0 - self.reset_highest_similarity = 0.0 self.split_below_threshold = False self.current_image_file_label.setText("-") + self.table_current_image_live_label.setText("-") + self.table_current_image_highest_label.setText("-") + self.table_current_image_threshold_label.setText("-") self.start_image_status_value_label.setText("not found") + # Reset related + self.reset_highest_similarity = 0.0 self.table_reset_image_live_label.setText("N/A") self.table_reset_image_threshold_label.setText("N/A") self.table_reset_image_highest_label.setText("N/A") @@ -274,13 +279,8 @@ def __reload_images(self, started_by_button: bool = False, wait_for_delay: bool parse_and_validate_images(self) if self.start_image: - if not wait_for_delay and self.start_image.get_pause_time(self) > 0: - self.start_image_status_value_label.setText("paused") - self.table_current_image_highest_label.setText("-") - self.table_current_image_threshold_label.setText("-") - else: - self.start_image_status_value_label.setText("ready") - self.__update_split_image(self.start_image) + self.table_current_image_threshold_label.setText(decimal(self.start_image.get_similarity_threshold(self))) + self.start_image_status_value_label.setText("ready") self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) if self.reset_image: @@ -295,6 +295,8 @@ def __compare_capture_for_auto_start(self, capture: MatLike | None): if not self.start_image: raise ValueError("There are no Start Image. How did we even get here?") + # Note: Start Image pause time is actually done at the start of the splits loop + self.start_image_status_value_label.setText("ready") self.__update_split_image(self.start_image) @@ -319,6 +321,7 @@ def __compare_capture_for_auto_start(self, capture: MatLike | None): if below_flag and not self.split_below_threshold and similarity_diff >= 0: self.split_below_threshold = True return + if ( (below_flag and self.split_below_threshold and similarity_diff < 0 and is_valid_image(capture)) # noqa: PLR0916 # See above TODO or (not below_flag and similarity_diff >= 0) @@ -756,7 +759,7 @@ def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): QApplication.processEvents() if safe_to_reload_start_image: - self.reload_images_signal.emit(False, False) + self.reload_images_signal.emit(False) def __is_reset_image_is_paused(self): if not self.reset_image: diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index c74729f5..c1db519a 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod from collections.abc import Callable -from typing import TYPE_CHECKING, final +from typing import TYPE_CHECKING, ClassVar, final from cv2.typing import MatLike from PySide6 import QtCore @@ -21,7 +21,7 @@ class CaptureMethodBase: last_captured_image: MatLike | None = None _autosplit_ref: "AutoSplit" - _subscriptions: set[Callable[[MatLike | None], object]] = set() # FIXME: # noqa: RUF012 + _subscriptions: ClassVar = set[Callable[[MatLike | None], object]]() def __init__(self, autosplit: "AutoSplit"): self._autosplit_ref = autosplit @@ -59,15 +59,6 @@ def _push_new_frame_to_subscribers(self, frame: MatLike | None): for subscription in self._subscriptions: subscription(frame) - # This most likely means we lost capture - # (ie the captured window was closed, crashed, lost capture device, etc.) - if not is_valid_image(frame): - # Try to recover by using the window name - self._autosplit_ref.live_image.setText(self.window_recovery_message) - self._autosplit_ref.capture_method.recover_window( - self._autosplit_ref.settings_dict["captured_window_title"], - ) - class ThreadedLoopCaptureMethod(CaptureMethodBase, metaclass=ABCMeta): def __init__(self, autosplit: "AutoSplit"): @@ -101,6 +92,7 @@ def __read_loop(self): # optimisation on idle: no subscriber means no work needed return try: + captured_image = None if self.check_selected_region_exists(): captured_image = self._read_action() # HACK: When WindowsGraphicsCaptureMethod tries to get images too quickly, @@ -111,6 +103,17 @@ def __read_loop(self): else: self.last_captured_image = None self._push_new_frame_to_subscribers(None) + + # This most likely means we lost capture + # (ie the captured window was closed, crashed, lost capture device, etc.) + if not is_valid_image(captured_image): + # Try to recover by using the window name + self._autosplit_ref.live_image.setText(self.window_recovery_message) + recovered = self._autosplit_ref.capture_method.recover_window( + self._autosplit_ref.settings_dict["captured_window_title"], + ) + if recovered: + self._autosplit_ref.live_image.setText("Live Capture Region hidden") except Exception as exception: # noqa: BLE001 # We really want to catch everything here error = exception self._autosplit_ref.show_error_signal.emit( diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index ca39b90b..5abadd91 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -140,8 +140,10 @@ def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: initialize the new one with transfered subscriptions and update UI as needed. """ + # subscriptions = autosplit.capture_method._subscriptions autosplit.capture_method.close() autosplit.capture_method = CAPTURE_METHODS.get(selected_capture_method)(autosplit) + # autosplit.capture_method._subscriptions = subscriptions disable_selection_buttons = selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE autosplit.select_region_button.setDisabled(disable_selection_buttons) diff --git a/src/user_profile.py b/src/user_profile.py index 39efebc5..18899c86 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -154,14 +154,7 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s set_hotkey(autosplit, hotkey, hotkey_value) change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) - if autosplit.settings_dict["capture_method"] != CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - autosplit.capture_method.recover_window(autosplit.settings_dict["captured_window_title"]) - if not autosplit.capture_method.check_selected_region_exists(): - autosplit.live_image.setText( - "Reload settings after opening" - + f"\n{autosplit.settings_dict['captured_window_title']!r}" - + "\nto automatically load Capture Region", - ) + update_live_capture_region_setting(autosplit, autosplit.settings_dict["live_capture_region"]) if settings_widget_was_open: open_settings(autosplit) @@ -185,7 +178,7 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path # TODO: Should this check be in `__load_start_image` ? if not autosplit.is_running: - autosplit.reload_images_signal.emit(False, True) + autosplit.reload_images_signal.emit(False) def load_settings_on_open(autosplit: "AutoSplit"): From 225f2e23356130a237cf0a0402c6196e652af8f0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 26 Dec 2023 20:41:10 -0500 Subject: [PATCH 17/28] start custom safe signal --- src/capture_method/__init__.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 5abadd91..bb60aaeb 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -1,12 +1,15 @@ import asyncio from collections import OrderedDict +from collections.abc import Callable from dataclasses import dataclass from enum import Enum, EnumMeta, auto, unique from itertools import starmap from typing import TYPE_CHECKING, NoReturn, TypedDict, cast from _ctypes import COMError +from cv2.typing import MatLike from pygrabber.dshow_graph import FilterGraph +from PySide6 import QtCore from typing_extensions import Never, override from capture_method.BitBltCaptureMethod import BitBltCaptureMethod @@ -28,7 +31,7 @@ class Region(TypedDict): height: int -class CaptureMethodMeta(EnumMeta): +class CaptureMethodEnumMeta(EnumMeta): # Allow checking if simple string is enum @override def __contains__(self, other: object): @@ -41,7 +44,7 @@ def __contains__(self, other: object): @unique # TODO: Try StrEnum in Python 3.11 -class CaptureMethodEnum(Enum, metaclass=CaptureMethodMeta): +class CaptureMethodEnum(Enum, metaclass=CaptureMethodEnumMeta): # Allow TOML to save as a simple string @override def __repr__(self): @@ -209,3 +212,27 @@ async def get_camera_info(index: int, device_name: str): in await asyncio.gather(*starmap(get_camera_info, enumerate(named_video_inputs))) if camera_info is not None ] + + +class CaptureMethodSignal(QtCore.QObject): + """Made to look like a `QtCore.SignalInstance`, but with safe `connect`/`disconnect` methods.""" + + __frame_signal = QtCore.Signal(object) + + @override + def connect(self, slot: Callable[[MatLike | None], object]): # pyright: ignore[reportIncompatibleMethodOverride] + try: + return self.__frame_signal.connect(slot, QtCore.Qt.ConnectionType.UniqueConnection) + except RuntimeError: + pass + + @override + def disconnect(self, slot: Callable[[MatLike | None], object]): # pyright: ignore[reportIncompatibleMethodOverride] + try: + self.__frame_signal.disconnect(slot) + except RuntimeError: + pass + + @override + def emit(self, __frame: MatLike | None): # pyright: ignore[reportIncompatibleMethodOverride] + return self.__frame_signal.emit(__frame) From 1b3a57e7acee6d22f8876d53bc875b23f432564d Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 5 Jan 2024 23:52:39 -0500 Subject: [PATCH 18/28] some improvements to shown text --- src/capture_method/CaptureMethodBase.py | 2 +- src/capture_method/__init__.py | 12 ++++++------ src/user_profile.py | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index c1db519a..dddc368c 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -112,7 +112,7 @@ def __read_loop(self): recovered = self._autosplit_ref.capture_method.recover_window( self._autosplit_ref.settings_dict["captured_window_title"], ) - if recovered: + if recovered and not self._autosplit_ref.settings_dict["live_capture_region"]: self._autosplit_ref.live_image.setText("Live Capture Region hidden") except Exception as exception: # noqa: BLE001 # We really want to catch everything here error = exception diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index a686cd9c..3255f3c7 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -265,20 +265,20 @@ class CaptureMethodSignal(QtCore.QObject): __frame_signal = QtCore.Signal(object) - @override - def connect(self, slot: Callable[[MatLike | None], object]): # pyright: ignore[reportIncompatibleMethodOverride] + def subscribe_to_new_frame(self, slot: Callable[[MatLike | None], object]): try: return self.__frame_signal.connect(slot, QtCore.Qt.ConnectionType.UniqueConnection) except RuntimeError: pass - @override - def disconnect(self, slot: Callable[[MatLike | None], object]): # pyright: ignore[reportIncompatibleMethodOverride] + def unsubscribe_from_new_frame(self, slot: Callable[[MatLike | None], object]): try: self.__frame_signal.disconnect(slot) except RuntimeError: pass - @override - def emit(self, __frame: MatLike | None): # pyright: ignore[reportIncompatibleMethodOverride] + def _push_new_frame_to_subscribers(self, __frame: MatLike | None): return self.__frame_signal.emit(__frame) + + def __init__(self): + super().__init__() diff --git a/src/user_profile.py b/src/user_profile.py index 6ba80b3f..cb9017eb 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -155,6 +155,7 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) update_live_capture_region_setting(autosplit, autosplit.settings_dict["live_capture_region"]) + autosplit.update_live_image_details(None) if settings_widget_was_open: open_settings(autosplit) From d36d2f93a3d5343a535ef192405055415ba50fdf Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 00:35:16 -0500 Subject: [PATCH 19/28] Update comments and remove commented code --- src/AutoSplit.py | 2 +- src/capture_method/BitBltCaptureMethod.py | 3 -- src/capture_method/CaptureMethodBase.py | 7 ++-- .../DesktopDuplicationCaptureMethod.py | 1 - src/capture_method/ScrotCaptureMethod.py | 2 -- .../VideoCaptureDeviceCaptureMethod.py | 6 ++-- .../WindowsGraphicsCaptureMethod.py | 10 ++---- src/capture_method/XcbCaptureMethod.py | 2 -- src/capture_method/__init__.py | 33 ++----------------- 9 files changed, 13 insertions(+), 53 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 91a7bcc7..85228c4c 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -768,7 +768,7 @@ def __is_reset_image_is_paused(self): raise ValueError("There are no Reset Image. How did we even get here?") current_time = time() - # Check if Reset Image is paused because we recently resetted + # Check if Reset Image is paused because we recently reset paused = current_time - self.last_reset_time <= self.reset_image.get_pause_time(self) # Check if Reset Image is paused because we are too close to just starting # because the Reset Image being the same as the Start Image is a common use case. diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 695c860b..8f0b495e 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -52,9 +52,6 @@ def _read_action(self) -> MatLike | None: height = selection["height"] hwnd = self._autosplit_ref.hwnd - # if not self.check_selected_region_exists(): - # return None - # If the window closes while it's being manipulated, it could cause a crash try: window_dc = win32gui.GetWindowDC(hwnd) diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index dddc368c..31c9e16d 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -1,4 +1,4 @@ -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod from collections.abc import Callable from typing import TYPE_CHECKING, ClassVar, final @@ -21,6 +21,7 @@ class CaptureMethodBase: last_captured_image: MatLike | None = None _autosplit_ref: "AutoSplit" + # Making _subscriptions a ClassVar ensures the state will be shared across Methods _subscriptions: ClassVar = set[Callable[[MatLike | None], object]]() def __init__(self, autosplit: "AutoSplit"): @@ -60,7 +61,7 @@ def _push_new_frame_to_subscribers(self, frame: MatLike | None): subscription(frame) -class ThreadedLoopCaptureMethod(CaptureMethodBase, metaclass=ABCMeta): +class ThreadedLoopCaptureMethod(CaptureMethodBase, ABC): def __init__(self, autosplit: "AutoSplit"): super().__init__(autosplit) self.__capture_timer = QtCore.QTimer() @@ -88,6 +89,8 @@ def _read_action(self) -> MatLike | None: @final def __read_loop(self): + # Very useful debug print + # print("subscriptions:", len(self._subscriptions), [x.__name__ for x in self._subscriptions]) if len(self._subscriptions) == 0: # optimisation on idle: no subscriber means no work needed return diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 1ec37003..6051d1ee 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -42,7 +42,6 @@ def _read_action(self): selection = self._autosplit_ref.settings_dict["capture_region"] hwnd = self._autosplit_ref.hwnd hmonitor = win32api.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) - # if not hmonitor or not self.check_selected_region_exists(): if not hmonitor: return None diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index c1ffb887..5510ca86 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -25,8 +25,6 @@ class ScrotCaptureMethod(ThreadedLoopCaptureMethod): @override def _read_action(self): - # if not self.check_selected_region_exists(): - # return None xdisplay = Display() root = xdisplay.screen().root try: diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 14d8e617..6725ac59 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -73,8 +73,6 @@ def close(self): @override def _read_action(self): - # if not self.check_selected_region_exists(): - # return None, False try: result, image = self.capture_device.read() except cv2.error as cv2_error: @@ -99,8 +97,8 @@ def _read_action(self): y = min(selection["y"], image.shape[ImageShape.Y] - 1) x = min(selection["x"], image.shape[ImageShape.X] - 1) image = image[ - y: y + selection["height"], - x: x + selection["width"], + y : y + selection["height"], + x : x + selection["width"], ] return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 4b1c92fa..16fb84e2 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -90,7 +90,7 @@ def close(self): @override def set_fps_limit(self, fps: int): """ - There's an issue in the interraction between QTimer and WGC API where setting the interval to even 1 ms + There's an issue in the interaction between QTimer and WGC API where setting the interval to even 1 ms causes twice as many "called `try_get_next_frame` to fast. So for FPS target above 30, we unlock interval speed. """ @@ -99,12 +99,8 @@ def set_fps_limit(self, fps: int): @override def _read_action(self) -> MatLike | None: selection = self._autosplit_ref.settings_dict["capture_region"] - # We still need to check the hwnd because WGC will return a blank black image - if not ( - # self.check_selected_region_exists() and - # Only needed for the type-checker - self.frame_pool - ): + # Only needed for the type-checker + if not self.frame_pool: return None try: diff --git a/src/capture_method/XcbCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py index 5fcdb5d8..bcb6c095 100644 --- a/src/capture_method/XcbCaptureMethod.py +++ b/src/capture_method/XcbCaptureMethod.py @@ -24,8 +24,6 @@ class XcbCaptureMethod(ThreadedLoopCaptureMethod): @override def _read_action(self): - # if not self.check_selected_region_exists(): - # return None xdisplay = Display() root = xdisplay.screen().root try: diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 3255f3c7..0e26596e 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -4,14 +4,11 @@ import os import sys from collections import OrderedDict -from collections.abc import Callable from dataclasses import dataclass from enum import Enum, EnumMeta, auto, unique from itertools import starmap from typing import TYPE_CHECKING, TypedDict, cast -from cv2.typing import MatLike -from PySide6 import QtCore from typing_extensions import Never, override from capture_method.CaptureMethodBase import CaptureMethodBase @@ -168,14 +165,12 @@ def get(self, key: CaptureMethodEnum, __default: object = None): def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: "AutoSplit"): """ - Seemlessly change the current capture method, - initialize the new one with transfered subscriptions + Seamlessly change the current capture method, + initialize the new one with transferred subscriptions and update UI as needed. """ - # subscriptions = autosplit.capture_method._subscriptions autosplit.capture_method.close() autosplit.capture_method = CAPTURE_METHODS.get(selected_capture_method)(autosplit) - # autosplit.capture_method._subscriptions = subscriptions disable_selection_buttons = selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE autosplit.select_region_button.setDisabled(disable_selection_buttons) @@ -258,27 +253,3 @@ async def get_camera_info(index: int, device_name: str): in await asyncio.gather(*starmap(get_camera_info, enumerate(named_video_inputs))) if camera_info is not None ] - - -class CaptureMethodSignal(QtCore.QObject): - """Made to look like a `QtCore.SignalInstance`, but with safe `connect`/`disconnect` methods.""" - - __frame_signal = QtCore.Signal(object) - - def subscribe_to_new_frame(self, slot: Callable[[MatLike | None], object]): - try: - return self.__frame_signal.connect(slot, QtCore.Qt.ConnectionType.UniqueConnection) - except RuntimeError: - pass - - def unsubscribe_from_new_frame(self, slot: Callable[[MatLike | None], object]): - try: - self.__frame_signal.disconnect(slot) - except RuntimeError: - pass - - def _push_new_frame_to_subscribers(self, __frame: MatLike | None): - return self.__frame_signal.emit(__frame) - - def __init__(self): - super().__init__() From ef4b69d16143d8c67691899244ad29a2b2176699 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 00:35:36 -0500 Subject: [PATCH 20/28] Improve WGC Window Recovery --- .../WindowsGraphicsCaptureMethod.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 16fb84e2..5e0fb480 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -13,6 +13,7 @@ from winsdk.windows.graphics.capture import Direct3D11CaptureFramePool, GraphicsCaptureSession from winsdk.windows.graphics.capture.interop import create_for_window from winsdk.windows.graphics.directx import DirectXPixelFormat +from winsdk.windows.graphics.directx.direct3d11 import IDirect3DSurface from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod @@ -28,6 +29,10 @@ WGC_QTIMER_LIMIT = 30 +async def convert_d3d_surface_to_software_bitmap(surface: IDirect3DSurface | None): + return await SoftwareBitmap.create_copy_from_surface_async(surface) + + class WindowsGraphicsCaptureMethod(ThreadedLoopCaptureMethod): name = "Windows Graphics Capture" short_description = "fast, most compatible, capped at 60fps" @@ -113,11 +118,8 @@ def _read_action(self) -> MatLike | None: if not frame: return self.last_captured_image - async def coroutine(): - return await SoftwareBitmap.create_copy_from_surface_async(frame.surface) - try: - software_bitmap = asyncio.run(coroutine()) + software_bitmap = asyncio.run(convert_d3d_surface_to_software_bitmap(frame.surface)) except SystemError as exception: # HACK: can happen when closing the GraphicsCapturePicker if str(exception).endswith("returned a result with an error set"): @@ -128,7 +130,7 @@ async def coroutine(): # HACK: Can happen when starting the region selector # TODO: Validate if this is still true return self.last_captured_image - # raise ValueError("Unable to convert Direct3D11CaptureFrame to SoftwareBitmap.") + # raise ValueError("Unable to convert IDirect3DSurface to SoftwareBitmap.") bitmap_buffer = software_bitmap.lock_buffer(BitmapBufferAccessMode.READ_WRITE) if not bitmap_buffer: raise ValueError("Unable to obtain the BitmapBuffer from SoftwareBitmap.") @@ -145,6 +147,13 @@ def recover_window(self, captured_window_title: str): hwnd = win32gui.FindWindow(None, captured_window_title) if not is_valid_hwnd(hwnd): return False + + # Because of async image obtention and capture initialization, + # AutoSplit could ask for an image too soon after having called recover_window() last iteration. + # WGC *would* have returned an image, but it's asked to reinitialize over again. + if self._autosplit_ref.hwnd == hwnd and self.check_selected_region_exists(): + return True + self._autosplit_ref.hwnd = hwnd try: self.reinitialize() From b1625b934da69a5fa3048163fab35eeec7eca0ab Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 01:15:15 -0500 Subject: [PATCH 21/28] Test dependencies and linting update --- .pre-commit-config.yaml | 2 +- pyproject.toml | 18 +++++++++++------- scripts/install.ps1 | 7 +++++-- scripts/requirements-dev.txt | 4 ++-- scripts/requirements.txt | 2 +- src/AutoSplit.py | 3 +-- src/capture_method/__init__.py | 8 +++----- typings/scipy/fft/_realtransforms.pyi | 8 -------- 8 files changed, 24 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7e94e8c..71be36f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: pretty-format-ini args: [--autofix] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.14" # Must match requirements-dev.txt + rev: "v0.3.2" # Must match requirements-dev.txt hooks: - id: ruff args: [--fix] diff --git a/pyproject.toml b/pyproject.toml index 32dd3d02..a0b91430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,10 @@ [tool.ruff] target-version = "py310" line-length = 120 -select = ["ALL"] preview = true + +[tool.ruff.lint] +select = ["ALL"] # https://docs.astral.sh/ruff/rules/ ignore = [ ### @@ -58,14 +60,15 @@ ignore = [ # Python 3.11, introduced "zero cost" exception handling "PERF203", # try-except-in-loop - ### FIXME/TODO (no warnings in Ruff yet: https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774): + ### FIXME/TODO: I'd normally set them as temporarily warnings, but no warnings in Ruff yet: + ### https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774): "CPY001", # flake8-copyright "PTH", # flake8-use-pathlib # Ignore until linux support "EXE", # flake8-executable ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "typings/**/*.pyi" = [ "F811", # Re-exports false positives "F821", # https://github.com/astral-sh/ruff/issues/3011 @@ -82,11 +85,11 @@ ignore = [ ] # https://docs.astral.sh/ruff/settings/#flake8-implicit-str-concat -[tool.ruff.flake8-implicit-str-concat] +[tool.ruff.lint.flake8-implicit-str-concat] allow-multiline = false # https://docs.astral.sh/ruff/settings/#isort -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports = true split-on-trailing-comma = false # Unlike isort, Ruff only counts relative imports as local-folder by default for know. @@ -108,13 +111,13 @@ known-local-folder = [ ] # https://docs.astral.sh/ruff/settings/#mccabe -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] # Hard limit, arbitrary to 4 bytes max-complexity = 31 # Arbitrary to 2 bytes, same as SonarLint # max-complexity = 15 -[tool.ruff.pylint] +[tool.ruff.lint.pylint] # Arbitrary to 1 byte, same as SonarLint max-args = 7 # At least same as max-complexity @@ -133,6 +136,7 @@ ignore = [ # Autofixed by Ruff # Check for the "Fix" flag https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "E2", # Whitespace + "E3", # Blank lines "E703", # useless-semicolon "E71", # Statement (comparisons) "E731", # lambda-assignment diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6072c5a3..9be64cbc 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -40,6 +40,9 @@ If ($IsLinux) { } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. &"$python" -m pip install wheel pip setuptools --upgrade +# Upgrading QT to 6.6.2 w/o first uninstalling shiboken6 can lead to issues +# https://bugreports.qt.io/browse/PYSIDE-2616?focusedId=777285&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-777285 +&"$python" -m pip uninstall shiboken6 &"$python" -m pip install -r "$PSScriptRoot/requirements$dev.txt" --upgrade # These libraries install extra requirements we don't want # Open suggestion for support in requirements files: https://github.com/pypa/pip/issues/9948 & https://github.com/pypa/pip/pull/10837 @@ -72,11 +75,11 @@ If ($IsLinux) { Set-Content "$libPath/QtWidgets.pyi" } # Uninstall optional dependencies if PyAutoGUI or D3DShot was installed outside this script -# pyscreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed on Windows +# PyScreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed on Windows # Pillow, pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 # (also --exclude from build script, but more consistent with unfrozen run) &"$python" -m pip uninstall pyscreenshot mss pygetwindow pymsgbox pytweening MouseInfo -y -If ($IsWindows) { &"$python" -m pip uninstall pyscreeze Pillow -y } +If ($IsWindows) { &"$python" -m pip uninstall PyScreeze Pillow -y } # Don't compile resources on the Build CI job as it'll do so in build script If ($dev) { diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 370084ba..5e242a22 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -14,7 +14,7 @@ # Linters & Formatters add-trailing-comma>=3.1.0 # Must match .pre-commit-config.yaml autopep8>=2.0.4 # Must match .pre-commit-config.yaml -ruff>=0.1.14 # New checks # Must match .pre-commit-config.yaml +ruff>=0.3.2 # New checks # Must match .pre-commit-config.yaml # # Types types-D3DShot ; sys_platform == 'win32' @@ -23,5 +23,5 @@ types-psutil types-PyAutoGUI types-pyinstaller types-python-xlib ; sys_platform == 'linux' -types-pywin32>=306.0.0.8 ; sys_platform == 'win32' +types-pywin32>=306.0.0.20240130 ; sys_platform == 'win32' types-toml diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 0357b646..e89669ef 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -29,5 +29,5 @@ winsdk>=1.0.0b10 ; sys_platform == 'win32' # Python 3.12 support # D3DShot # See install.ps1 # # Linux-only dependencies -pyscreeze ; sys_platform == 'linux' +PyScreeze ; sys_platform == 'linux' python-xlib ; sys_platform == 'linux' diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 6e190be9..49328011 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -943,8 +943,7 @@ def set_preview_image(qlabel: QLabel, image: MatLike | None): capture = image qimage = QtGui.QImage( - # https://bugreports.qt.io/browse/PYSIDE-2476 - capture.data, # pyright: ignore[reportCallIssue, reportArgumentType] + capture.data, width, height, width * channels, diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 8569a197..3c5c28b1 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -1,5 +1,3 @@ -# pyright: reportUnknownMemberType=false -# Complete type for PIL.features.check_feature upstream import asyncio import os import sys @@ -120,7 +118,7 @@ def __getitem__( # type:ignore[override] # pyright: ignore[reportIncompatibleMe return super().__getitem__(__key) @override - def get(self, key: CaptureMethodEnum, __default: object = None): + def get(self, key: CaptureMethodEnum, default: object = None, /): """ Returns the `CaptureMethodBase` subclass for `CaptureMethodEnum` if `CaptureMethodEnum` is available, else defaults to the first available `CaptureMethodEnum`. @@ -165,8 +163,8 @@ def get(self, key: CaptureMethodEnum, __default: object = None): def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: "AutoSplit"): """ - Seemlessly change the current capture method, - initialize the new one with transfered subscriptions + Seamlessly change the current capture method, + initialize the new one with transferred subscriptions and update UI as needed. """ autosplit.capture_method.close() diff --git a/typings/scipy/fft/_realtransforms.pyi b/typings/scipy/fft/_realtransforms.pyi index 7c28f8a8..fd68b022 100644 --- a/typings/scipy/fft/_realtransforms.pyi +++ b/typings/scipy/fft/_realtransforms.pyi @@ -4,7 +4,6 @@ from numpy.typing import NDArray __all__ = ["dct", "dctn", "dst", "dstn", "idct", "idctn", "idst", "idstn"] - def dctn( x, type=2, @@ -17,7 +16,6 @@ def dctn( orthogonalize=None, ): ... - def idctn( x, type=2, @@ -29,7 +27,6 @@ def idctn( orthogonalize=None, ): ... - def dstn( x, type=2, @@ -41,7 +38,6 @@ def dstn( orthogonalize=None, ): ... - def idstn( x, type=2, @@ -53,7 +49,6 @@ def idstn( orthogonalize=None, ): ... - def dct( x: NDArray[generic], type: int = 2, @@ -65,7 +60,6 @@ def dct( orthogonalize: Incomplete | None = None, ) -> NDArray[float64]: ... - def idct( x, type=2, @@ -77,7 +71,6 @@ def idct( orthogonalize=None, ): ... - def dst( x, type=2, @@ -89,7 +82,6 @@ def dst( orthogonalize=None, ): ... - def idst( x, type=2, From bc3e778a59355d904f5879d336689305a20f0f42 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 01:57:28 -0500 Subject: [PATCH 22/28] Reload images on loading profile Don't check if capture region is valid to reload image Allow screenshots w/o a splits folder Validate screenshot location is valid and exists Move validate_before_parsing to split_parser.py --- src/AutoSplit.py | 22 ++++++++++++++-------- src/error_messages.py | 6 +++--- src/region_selection.py | 15 --------------- src/split_parser.py | 25 +++++++++++++++++++++++-- src/user_profile.py | 4 +--- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index cec4fde7..51dcd159 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -33,8 +33,8 @@ open_update_checker, view_help, ) -from region_selection import align_region, select_region, select_window, validate_before_parsing -from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images +from region_selection import align_region, select_region, select_window +from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images, validate_before_parsing from user_profile import DEFAULT_PROFILE from utils import ( AUTOSPLIT_VERSION, @@ -277,7 +277,7 @@ def __reload_images(self, started_by_button: bool = False): self.table_reset_image_highest_label.setText("N/A") set_preview_image(self.current_split_image, None) - if validate_before_parsing(self, started_by_button): + if validate_before_parsing(self, show_error=started_by_button, check_region_exists=False): parse_and_validate_images(self) if self.start_image: @@ -365,7 +365,16 @@ def __update_height(self): self.settings_dict["capture_region"]["height"] = self.height_spinbox.value() def __take_screenshot(self): - if not validate_before_parsing(self, check_empty_directory=False): + if not self.capture_method.check_selected_region_exists(): + error_messages.region() + return + + screenshot_directory = self.settings_dict["screenshot_directory"] or self.settings_dict["split_image_directory"] + if not screenshot_directory: + error_messages.split_image_directory() + return + if not os.path.exists(screenshot_directory): + error_messages.invalid_directory(screenshot_directory) return # Check if file exists and rename it if it does. @@ -373,10 +382,7 @@ def __take_screenshot(self): # which is a problem, but I doubt anyone will get to 1000 split images... screenshot_index = 1 while True: - screenshot_path = os.path.join( - self.settings_dict["screenshot_directory"] or self.settings_dict["split_image_directory"], - f"{screenshot_index:03}_SplitImage.png", - ) + screenshot_path = os.path.join(screenshot_directory, f"{screenshot_index:03}_SplitImage.png") if not os.path.exists(screenshot_path): break screenshot_index += 1 diff --git a/src/error_messages.py b/src/error_messages.py index cd006c95..8c053e38 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -43,11 +43,11 @@ def set_text_message(message: str, details: str = "", kill_button: str = "", acc def split_image_directory(): - set_text_message("No split image folder is selected.") + set_text_message("No Split Image Folder is selected.") -def split_image_directory_not_found(): - set_text_message("The Split Image Folder does not exist.") +def invalid_directory(directory: str): + set_text_message(f"Folder {directory} is invalid or does not exist.") def split_image_directory_empty(): diff --git a/src/region_selection.py b/src/region_selection.py index c53fad07..8661ee58 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -307,21 +307,6 @@ def __test_alignment(capture: MatLike, template: MatLike): return best_match, best_height, best_width, best_loc -def validate_before_parsing(autosplit: "AutoSplit", show_error: bool = True, check_empty_directory: bool = True): - error = None - if not autosplit.settings_dict["split_image_directory"]: - error = error_messages.split_image_directory - elif not os.path.isdir(autosplit.settings_dict["split_image_directory"]): - error = error_messages.split_image_directory_not_found - elif check_empty_directory and not os.listdir(autosplit.settings_dict["split_image_directory"]): - error = error_messages.split_image_directory_empty - elif not autosplit.capture_method.check_selected_region_exists(): - error = error_messages.region - if error and show_error: - error() - return not error - - class BaseSelectWidget(QtWidgets.QWidget): selection: Region | None = None diff --git a/src/split_parser.py b/src/split_parser.py index 7c2ab55c..96170cdd 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -173,6 +173,27 @@ def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): return None +def validate_before_parsing( + autosplit: "AutoSplit", + *, + show_error: bool = True, + check_region_exists: bool = True, +): + error = None + split_image_directory = autosplit.settings_dict["split_image_directory"] + if not split_image_directory: + error = error_messages.split_image_directory + elif not os.path.isdir(split_image_directory): + error = partial(error_messages.invalid_directory, split_image_directory) + elif not os.listdir(split_image_directory): + error = error_messages.split_image_directory_empty + elif check_region_exists and not autosplit.capture_method.check_selected_region_exists(): + error = error_messages.region + if error and show_error: + error() + return not error + + def parse_and_validate_images(autosplit: "AutoSplit"): # Get split images all_images = [ @@ -224,12 +245,12 @@ def parse_and_validate_images(autosplit: "AutoSplit"): # Check that there's only one Reset Image if image.image_type == ImageType.RESET: - error_message = lambda: error_messages.multiple_keyword_images(RESET_KEYWORD) # noqa: E731 + error_message = partial(error_messages.multiple_keyword_images, RESET_KEYWORD) break # Check that there's only one Start Image if image.image_type == ImageType.START: - error_message = lambda: error_messages.multiple_keyword_images(START_KEYWORD) # noqa: E731 + error_message = partial(error_messages.multiple_keyword_images, START_KEYWORD) break if error_message: diff --git a/src/user_profile.py b/src/user_profile.py index cb9017eb..51b6d9a2 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -156,9 +156,7 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) update_live_capture_region_setting(autosplit, autosplit.settings_dict["live_capture_region"]) autosplit.update_live_image_details(None) - - if settings_widget_was_open: - open_settings(autosplit) + autosplit.reload_images_signal.emit(False) if settings_widget_was_open: open_settings(autosplit) From cf8856cc34e2fb45c94891bbc18cb1dddc5689b2 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 12:54:25 -0500 Subject: [PATCH 23/28] Apply flake8-boolean-trap rule Some false-positives: https://github.com/astral-sh/ruff/issues/3247 https://github.com/astral-sh/ruff/issues/9497 --- pyproject.toml | 5 ++++- src/AutoSplit.py | 32 ++++++++++++++++---------------- src/hotkeys.py | 4 ++-- src/menu_bar.py | 12 ++++++------ src/split_parser.py | 2 +- src/user_profile.py | 2 +- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0b91430..41b4e3a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,9 @@ ignore = [ "D1", # pydocstyle Missing doctring "D401", # pydocstyle: non-imperative-mood "EM", # flake8-errmsg - "FBT", # flake8-boolean-trap + # This is often something we can't control: https://github.com/astral-sh/ruff/issues/9497 + # Also false-positive with positional-only arguments: https://github.com/astral-sh/ruff/issues/3247 + "FBT003", # flake8-boolean-trap: boolean-positional-value-in-call "INP", # flake8-no-pep420 "ISC003", # flake8-implicit-str-concat: explicit-string-concatenation # Short messages are still considered "long" messages @@ -74,6 +76,7 @@ ignore = [ "F821", # https://github.com/astral-sh/ruff/issues/3011 # The following can't be controlled for external libraries: "A", # Shadowing builtin names + "FBT", # flake8-boolean-trap "ICN001", # unconventional-import-alias "N8", # Naming conventions "PLC2701", # Private name import diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 6c044263..4ad3d376 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -172,11 +172,11 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.reset_button.clicked.connect(self.reset) self.skip_split_button.clicked.connect(self.skip_split) self.undo_split_button.clicked.connect(self.undo_split) - self.next_image_button.clicked.connect(lambda: self.skip_split(True)) - self.previous_image_button.clicked.connect(lambda: self.undo_split(True)) + self.next_image_button.clicked.connect(lambda: self.skip_split(navigate_image_only=True)) + self.previous_image_button.clicked.connect(lambda: self.undo_split(navigate_image_only=True)) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_start_image_button.clicked.connect(lambda: self.__reload_start_image(True, True)) + self.reload_start_image_button.clicked.connect(lambda: self.__reload_start_image(started_by_button=True)) self.action_check_for_updates_on_open.changed.connect( lambda: user_profile.set_check_for_updates_on_open(self, self.action_check_for_updates_on_open.isChecked()), ) @@ -191,8 +191,8 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.after_setting_hotkey_signal.connect(lambda: after_setting_hotkey(self)) self.start_auto_splitter_signal.connect(self.__auto_splitter) - def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool): - return open_update_checker(self, latest_version, check_on_open) + def _update_checker_widget_signal_slot(latest_version: str, *, check_on_open: bool): + return open_update_checker(self, latest_version, check_on_open=check_on_open) self.update_checker_widget_signal.connect(_update_checker_widget_signal_slot) self.reload_start_image_signal.connect(self.__reload_start_image) @@ -203,7 +203,7 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) self.screenshot_signal.connect(self.__take_screenshot) # live image checkbox - self.timer_live_image.timeout.connect(lambda: self.__update_live_image_details(None, True)) + self.timer_live_image.timeout.connect(lambda: self.__update_live_image_details(None, called_from_timer=True)) self.timer_live_image.start(int(ONE_SECOND / self.settings_dict["fps_limit"])) # Automatic timer start @@ -242,7 +242,7 @@ def __browse(self): self.split_image_folder_input.setText(f"{new_split_image_directory}/") self.reload_start_image_signal.emit(False, True) - def __update_live_image_details(self, capture: MatLike | None, called_from_timer: bool = False): + def __update_live_image_details(self, capture: MatLike | None, *, called_from_timer: bool = False): # HACK: Since this is also called in __get_capture_for_comparison, # we don't need to update anything if the app is running if called_from_timer: @@ -265,7 +265,7 @@ def __update_live_image_details(self, capture: MatLike | None, called_from_timer else: set_preview_image(self.live_image, capture) - def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): + def __reload_start_image(self, *, started_by_button: bool = False, wait_for_delay: bool = True): """ Not thread safe (if triggered by LiveSplit for example). Use `reload_start_image_signal.emit` instead. @@ -285,7 +285,7 @@ def __reload_start_image(self, started_by_button: bool = False, wait_for_delay: self.start_image_status_value_label.setText("not found") set_preview_image(self.current_split_image, None) - if not (validate_before_parsing(self, started_by_button) and parse_and_validate_images(self)): + if not (validate_before_parsing(self, show_error=started_by_button) and parse_and_validate_images(self)): QApplication.processEvents() return @@ -451,7 +451,7 @@ def __is_current_split_out_of_range(self): or self.split_image_number > len(self.split_images_and_loop_number) - 1 ) - def undo_split(self, navigate_image_only: bool = False): + def undo_split(self, *, navigate_image_only: bool = False): """Undo Split" and "Prev. Img." buttons connect to here.""" # Can't undo until timer is started # or Undoing past the first image @@ -475,7 +475,7 @@ def undo_split(self, navigate_image_only: bool = False): if not navigate_image_only: send_command(self, "undo") - def skip_split(self, navigate_image_only: bool = False): + def skip_split(self, *, navigate_image_only: bool = False): """Skip Split" and "Next Img." buttons connect to here.""" # Can't skip or split until timer is started # or Splitting/skipping when there are no images left @@ -528,13 +528,13 @@ def start_auto_splitter(self): def __check_for_reset_state_update_ui(self): """Check if AutoSplit is started, if not then update the GUI.""" if not self.is_running: - self.gui_changes_on_reset(True) + self.gui_changes_on_reset(safe_to_reload_start_image=True) return True return False def __auto_splitter(self): # noqa: PLR0912,PLR0915 if not self.settings_dict["split_hotkey"] and not self.is_auto_controlled: - self.gui_changes_on_reset(True) + self.gui_changes_on_reset(safe_to_reload_start_image=True) error_messages.split_hotkey() return @@ -544,7 +544,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 if not (validate_before_parsing(self) and parse_and_validate_images(self)): # `safe_to_reload_start_image: bool = False` because __reload_start_image also does this check, # we don't want to double a Start/Reset Image error message - self.gui_changes_on_reset(False) + self.gui_changes_on_reset() return # Construct a list of images + loop count tuples. @@ -647,7 +647,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 # loop breaks to here when the last image splits self.is_running = False - self.gui_changes_on_reset(True) + self.gui_changes_on_reset(safe_to_reload_start_image=True) def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_array: list[bool]): """ @@ -771,7 +771,7 @@ def gui_changes_on_start(self): QApplication.processEvents() - def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): + def gui_changes_on_reset(self, *, safe_to_reload_start_image: bool = False): self.start_auto_splitter_button.setText("Start Auto Splitter") self.image_loop_value_label.setText("N/A") self.current_split_image.clear() diff --git a/src/hotkeys.py b/src/hotkeys.py index c6ce9d42..f333b9f3 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -232,9 +232,9 @@ def __get_hotkey_action(autosplit: "AutoSplit", hotkey: Hotkey): if hotkey == "split": return autosplit.start_auto_splitter if hotkey == "skip_split": - return lambda: autosplit.skip_split(True) + return lambda: autosplit.skip_split(navigate_image_only=True) if hotkey == "undo_split": - return lambda: autosplit.undo_split(True) + return lambda: autosplit.undo_split(navigate_image_only=True) if hotkey == "toggle_auto_reset_image": def toggle_auto_reset_image(): diff --git a/src/menu_bar.py b/src/menu_bar.py index 91b0b46a..810713f2 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -58,7 +58,7 @@ def open_about(autosplit: "AutoSplit"): class __UpdateCheckerWidget(QtWidgets.QWidget, update_checker.Ui_UpdateChecker): # noqa: N801 # Private class - def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, check_on_open: bool = False): + def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, *, check_on_open: bool = False): super().__init__() self.setupUi(self) self.current_version_number_label.setText(AUTOSPLIT_VERSION) @@ -89,9 +89,9 @@ def do_not_ask_me_again_state_changed(self): ) -def open_update_checker(autosplit: "AutoSplit", latest_version: str, check_on_open: bool): +def open_update_checker(autosplit: "AutoSplit", latest_version: str, *, check_on_open: bool): if not autosplit.UpdateCheckerWidget or cast(QtWidgets.QWidget, autosplit.UpdateCheckerWidget).isHidden(): - autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open) + autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open=check_on_open) def view_help(): @@ -99,7 +99,7 @@ def view_help(): class __CheckForUpdatesThread(QtCore.QThread): # noqa: N801 # Private class - def __init__(self, autosplit: "AutoSplit", check_on_open: bool): + def __init__(self, autosplit: "AutoSplit", *, check_on_open: bool): super().__init__() self._autosplit_ref = autosplit self.check_on_open = check_on_open @@ -124,8 +124,8 @@ def about_qt_for_python(): webbrowser.open("https://wiki.qt.io/Qt_for_Python") -def check_for_updates(autosplit: "AutoSplit", check_on_open: bool = False): - autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open) +def check_for_updates(autosplit: "AutoSplit", *, check_on_open: bool = False): + autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open=check_on_open) autosplit.CheckForUpdatesThread.start() diff --git a/src/split_parser.py b/src/split_parser.py index 029433fa..374d0a25 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -173,7 +173,7 @@ def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): return None -def validate_before_parsing(autosplit: "AutoSplit", show_error: bool = True): +def validate_before_parsing(autosplit: "AutoSplit", *, show_error: bool = True): error = None split_image_directory = autosplit.settings_dict["split_image_directory"] if not split_image_directory: diff --git a/src/user_profile.py b/src/user_profile.py index 14485121..51334c84 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -231,7 +231,7 @@ def load_check_for_updates_on_open(autosplit: "AutoSplit"): autosplit.action_check_for_updates_on_open.setChecked(value) -def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bool): +def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bool): # noqa: FBT001 """Sets the "Check For Updates On Open" QSettings value and the checkbox state.""" design_window.action_check_for_updates_on_open.setChecked(value) QtCore.QSettings( From f2395004cd2132a8cebf150883bb4f35406e15ce Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 14:18:52 -0500 Subject: [PATCH 24/28] Split-off tutorial from main README --- README.md | 207 ++--------------------------------------------- docs/tutorial.md | 196 ++++++++++++++++++++++++++++++++++++++++++++ src/menu_bar.py | 2 +- 3 files changed, 205 insertions(+), 200 deletions(-) create mode 100644 docs/tutorial.md diff --git a/README.md b/README.md index fa058298..a19f4627 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,15 @@ This program can be used to automatically start, split, and reset your preferred ![Example](/docs/2.0.0_gif.gif) -# TUTORIAL +## Tutorial -## DOWNLOAD AND OPEN +To understand how to use AutoSplit and how it works in-depth, please read the [tutorial](/docs/tutorial.md). + +## Download and open - Download the [latest version](/../../releases/latest) - You can also check out the [latest dev builds](/../../actions/workflows/lint-and-build.yml?query=event%3Apush+is%3Asuccess) (requires a GitHub account) (If you don't have a GitHub account, you can try [nightly.link](https://nightly.link/Toufool/AutoSplit/workflows/lint-and-build/dev)) - - Linux users must ensure they are in the `tty` and `input` groups and have write access to `/dev/uinput`. You can run the following commands to do so: @@ -52,196 +53,11 @@ This program can be used to automatically start, split, and reset your preferred - WSL2/WSLg requires an additional Desktop Environment, external X11 server, and/or systemd - Python 3.10+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). -## OPTIONS - -#### Split Image Folder - -- Supported image file types: PNG, JPEG, bitmaps, WebP, and [more](https://docs.opencv.org/4.8.0/d4/da8/group__imgcodecs.html#imread). -- Images can be any size and ratio. -- Images are matched in alphanumerical order. -- Recommended filenaming convention: `001_SplitName.png, 002_SplitName.png, 003_SplitName.png`... -- Custom split image settings are handled in the filename. See how [here](#custom-split-image-settings). -- To create split images, it is recommended to use AutoSplit's Take Screenshot button for accuracy. However, images can be created using any method including Print Screen and [Snipping Tool](https://support.microsoft.com/en-us/help/4027213/windows-10-open-snipping-tool-and-take-a-screenshot). - -#### Capture Region - -- This is the region that your split images are compared to. Usually, this is going to be the full game screen. -- Click "Select Region". -- Click and drag to form a rectangle over the region you want to capture. -- Adjust the x, y, width, and height of the capture region manually to make adjustments as needed. -- If you want to align your capture region by using a reference image, click "Align Region". -- You can freely move the window that the program is capturing, but resizing the window will cause the capture region to change. -- Once you are happy with your capture region, you may unselect Live Capture Region to decrease CPU usage if you wish. -- You can save a screenshot of the capture region to your split image folder using the Take Screenshot button. - -#### Avg. FPS - -- Calculates the average comparison rate of the capture region to split images. This value will likely be much higher than needed, so it is highly recommended to limit your FPS depending on the frame rate of the game you are capturing. - -### Settings - -#### Comparison Method - -- There are three comparison methods to choose from: L2 Norm, Histograms, and Perceptual Hash (or pHash). - - L2 Norm: This method should be fine to use for most cases. It finds the difference between each pixel, squares it, sums it over the entire image and takes the square root. This is very fast but is a problem if your image is high frequency. Any translational movement or rotation can cause similarity to be very different. - - Histograms: An explanation on Histograms comparison can be found [here](https://mpatacchiola.github.io/blog/2016/11/12/the-simplest-classifier-histogram-intersection.html). This is a great method to use if you are using several masked images. - > This algorithm is particular reliable when the colour is a strong predictor of the object identity. The histogram intersection [...] is robust to occluding objects in the foreground. - - Perceptual Hash: An explanation on pHash comparison can be found [here](http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html). It is highly recommended to NOT use pHash if you use masked images, or it'll be very inaccurate. - -#### Capture Method - - -##### Windows - -- **Windows Graphics Capture** (fast, most compatible, capped at 60fps) - Only available in Windows 10.0.17134 and up. - Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled. - Allows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows. - Adds a yellow border on Windows 10 (not on Windows 11). - Caps at around 60 FPS. -- **BitBlt** (fastest, least compatible) - The best option when compatible. But it cannot properly record OpenGL, Hardware Accelerated or Exclusive Fullscreen windows. - The smaller the selected region, the more efficient it is. -- **Direct3D Desktop Duplication** (slower, bound to display) - Duplicates the desktop using Direct3D. - It can record OpenGL and Hardware Accelerated windows. - About 10-15x slower than BitBlt. Not affected by window size. - Overlapping windows will show up and can't record across displays. - This option may not be available for hybrid GPU laptops, see [D3DDD-Note-Laptops.md](/docs/D3DDD-Note-Laptops.md) for a solution. -- **Force Full Content Rendering** (very slow, can affect rendering) - Uses BitBlt behind the scene, but passes a special flag to PrintWindow to force rendering the entire desktop. - About 10-15x slower than BitBlt based on original window size and can mess up some applications' rendering pipelines. - -##### Linux - -- **X11 XCB** (fast, requires XCB) - Uses the XCB library to take screenshots of the X11 server. -- **Scrot** (very slow, may leave files) - Uses Scrot (SCReenshOT) to take screenshots. - Leaves behind a screenshot file if interrupted. - - "scrot" must be installed: `sudo apt-get install scrot` - -##### All platforms - -- **Video Capture Device** - Uses a Video Capture Device, like a webcam, virtual cam, or capture card. - -#### Capture Device - -Select the Video Capture Device that you wanna use if selecting the `Video Capture Device` Capture Method. - - -#### Show Live Similarity - -- Displays the live similarity between the capture region and the current split image. This number is between 0 and 1, with 1 being a perfect match. - -#### Show Highest Similarity - -- Shows the highest similarity between the capture region and current split image. - -#### Current Similarity Threshold - -- When the live similarity goes above this value, the program hits your split hotkey and moves to the next split image. - -#### Default Similarity Threshold - -- This value will be set as the threshold for an image if there is no custom threshold set for that image. - -#### Default Delay Time - -- Time in milliseconds that the program waits before hitting the split hotkey for that specific split if there is no custom Delay Time set for that image. - -#### Default Pause Time - -- Time in seconds that the program stops comparison after a split if there is no custom Pause Time set for that image. Useful for if you have two of the same split images in a row and want to avoid double-splitting. Also useful for reducing CPU usage. - -#### Dummy splits when undoing / skipping - -AutoSplit will group dummy splits together with a real split when undoing/skipping. This basically allows you to tie one or more dummy splits to a real split to keep it as in sync as possible with the real splits in LiveSplit/wsplit. If they are out of sync, you can always use "Previous Image" and "Next Image". - -Examples: -Given these splits: 1 dummy, 2 normal, 3 dummy, 4 dummy, 5 normal, 6 normal. - -In this situation you would have only 3 splits in LiveSplit/wsplit (even though there are 6 split images, only 3 are "real" splits). This basically results in 3 groups of splits: 1st split is images 1 and 2. 2nd split is images 3, 4 and 5. 3rd split is image 6. - -- If you are in the 1st or 2nd image and press the skip key, it will end up on the 3rd image -- If you are in the 3rd, 4th or 5th image and press the undo key, it will end up on the 2nd image -- If you are in the 3rd, 4th or 5th image and press the skip key, it will end up on the 6th image -- If you are in the 6th image and press the undo key, it will end up on the 5th image - -#### Loop last Split Image to first Split Image - -If this option is enabled, when the last split meets the threshold and splits, AutoSplit will loop back to the first split image and continue comparisons. -If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. -This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. - -#### Start also Resets - -If this option is enabled, a "Start" command (ie: from the Start Image) will also send the "Reset" command. This is useful if you want to automatically restart your timer using the Start Image. Since AutoSplit won't be running and won't be checking for the Reset Image. - -Having the reset image check be active at all time would be a better, more organic solution in the future. But that is dependent on migrating to an observer pattern () and being able to reload all images. - -#### Enable auto Reset Image - -This option is mainly meant to be toggled with the `Toggle auto Reset Image` hotkey. You can enable it to temporarily disable the Reset Image if you make a mistake in your run that would cause the Reset Image to trigger. Like exiting back to the game's menu (aka Save&Quit). - -### Custom Split Image Settings - -- Each split image can have different thresholds, pause times, delay split times, loop amounts, and can be flagged. -- These settings are handled in the image's filename. -- **Custom thresholds** are place between parenthesis `()` in the filename. This value will override the default threshold. -- **Custom pause times** are placed between square brackets `[]` in the filename. This value will override the default pause time. -- **Custom delay times** are placed between hash signs `##` in the filename. Note that these are in milliseconds. For example, a 10 second split delay would be `#10000#`. You cannot skip or undo splits during split delays. -- A different **comparison method** can be specified with their 0-base index between carets `^^`: - - `^0^`: L2 Norm - - `^1^`: Histogram - - `^2^`: Perceptual Hash -- **Image loop** amounts are placed between at symbols `@@` in the filename. For example, a specific image that you want to split 5 times in a row would be `@5@`. The current loop # is conveniently located beneath the current split image. -- **Flags** are placed between curly brackets `{}` in the filename. Multiple flags are placed in the same set of curly brackets. Current available flags: - - `{d}` **dummy split image**. When matched, it moves to the next image without hitting your split hotkey. - - `{b}` split when **similarity goes below** the threshold rather than above. When a split image filename has this flag, the split image similarity will go above the threshold, do nothing, and then split the next time the similarity goes below the threshold. - - `{p}` **pause flag**. When a split image filename has this flag, it will hit your pause hotkey rather than your split hokey. -- Filename examples: - - `001_SplitName_(0.9)_[10].png` is a split image with a threshold of 0.9 and a pause time of 10 seconds. - - `002_SplitName_(0.9)_[10]_{d}.png` is the second split image with a threshold of 0.9, pause time of 10, and is a dummy split. - - `003_SplitName_(0.85)_[20]_#3500#.png` is the third split image with a threshold of 0.85, pause time of 20 and has a delay split time of 3.5 seconds. - - `004_SplitName_(0.9)_[10]_#3500#_@3@_{b}.png` is the fourth split image with a threshold of 0.9, pause time of 10 seconds, delay split time of 3.5 seconds, will loop 3 times, and will split when similarity is below the threshold rather than above. - -## Special images - -### How to Create a Masked Image - -Masked images are very useful if only a certain part of the capture region is consistent (for example, consistent text on the screen, but the background is always different). Histogram or L2 norm comparison is recommended if you use any masked images. It is highly recommended that you do NOT use pHash comparison if you use any masked images, or it'll be very inaccurate. - -The best way to create a masked image is to set your capture region as the entire game screen, take a screenshot, and use a program like [paint.net](https://www.getpaint.net/) to "erase" (make transparent) everything you don't want the program to compare. More on creating images with transparency using paint.net can be found in [this tutorial](https://www.youtube.com/watch?v=v53kkUYFVn8). For visualization, here is what the capture region compared to a masked split image looks like if you would want to split on "Shine Get!" text in Super Mario Sunshine: - -![Mask Example](/docs/mask_example_image.png) - -### Reset Image - -You can have one (and only one) image with the keyword `reset` in its name. AutoSplit will press the reset button when it finds this image. This image will only be used for resets and it will not be tied to any split. You can set a threshold and pause time for it. The pause time is the amount of seconds AutoSplit will wait before checking for the Reset Image once the run starts. For example: `Reset_(0.95)_[10].png`. - -### Start Image - -The Start Image is similar to the Reset Image. You can only have one Start Image with the keyword `start_auto_splitter`.You can reload the image using the "`Reload Start Image`" button. The pause time is the amount of seconds AutoSplit will wait before starting comparisons of the first split image. Delay times will be used to delay starting your timer after the threshold is met. - -### Profiles - - -- Profiles use the extension `.toml`. Profiles can be saved and loaded by using `File -> Save Profile As...` and `File -> Load Profile`. -- The profile contains all of your settings, including information about the capture region. -- You can save multiple profiles, which is useful if you speedrun multiple games. -- If you change your display setup (like using a new monitor, or upgrading to Windows 11), you may need to readjust or reselect your Capture Region. - ## Timer Integration ### Timer Global Hotkeys -- Click "Set Hotkey" on each hotkey to set the hotkeys to AutoSplit. The Start / Split hotkey and Pause hotkey must be the same as the one used in your preferred timer program in order for the splitting/pausing to work properly. -- Make sure that Global Hotkeys are enabled in your speedrun timer. -- All of these actions can also be handled by their corresponding buttons. -- Note that pressing your Pause Hotkey does not serve any function in AutoSplit itself and is strictly used for the Pause flag. +Out of the box, AutoSplit works by listening for keyboard events and sending virtual keystrokes. This makes AutoSplit compatible with any timer by configuring your hotkeys to be the same. See the [Timer Global Hotkeys Tutorial](/docs/tutorial.md#timer-global-hotkeys). ### LiveSplit Integration @@ -250,20 +66,13 @@ The AutoSplit LiveSplit Component will directly connect AutoSplit with LiveSplit - Use hotkeys directly from LiveSplit to control AutoSplit and LiveSplit together - Load AutoSplit and any AutoSplit profile automatically when opening a LiveSplit layout. -#### LiveSplit Integration Tutorial - -- Click [here](https://github.com/Toufool/LiveSplit.AutoSplitIntegration/raw/main/update/Components/LiveSplit.AutoSplitIntegration.dll) to download the latest component. -- Place the .dll file into your `[...]\LiveSplit\Components` folder. -- Open LiveSplit -> Right Click -> Edit Layout -> Plus Button -> Control -> AutoSplit Integration. -- Click Layout Settings -> AutoSplit Integration -- Click the Browse buttons to locate your AutoSplit Path (path to AutoSplit executable) and Profile Path (path to your AutoSplit `.toml` profile file) respectively. - - If you have not yet set saved a profile, you can do so using AutoSplit, and then go back and set your Settings Path. -- Once set, click OK, and then OK again to close the Layout Editor. Right click LiveSplit -> Save Layout to save your layout. AutoSplit and your selected profile will now open automatically when opening that LiveSplit Layout `.lsl` file. +See the [installation instructions](https://github.com/Toufool/LiveSplit.AutoSplitIntegration#installation). ## Known Limitations - For many games, it will be difficult to find a split image for the last split of the run. - The window of the capture region cannot be minimized. +- Linux support is incomplete and we're [looking for contributors](../../issues?q=is%3Aissue+is%3Aopen+label%3A"help+wanted"+label%3ALinux+). ## Resources @@ -289,10 +98,10 @@ Not a developer? You can still help through the following methods: - - - - - - - - + - - - - diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 00000000..3167d377 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,196 @@ +# TUTORIAL + +## OPTIONS + +#### Split Image Folder + +- Supported image file types: PNG, JPEG, bitmaps, WebP, and [more](https://docs.opencv.org/4.8.0/d4/da8/group__imgcodecs.html#imread). +- Images can be any size and ratio. +- Images are matched in alphanumerical order. +- Recommended filenaming convention: `001_SplitName.png, 002_SplitName.png, 003_SplitName.png`... +- Custom split image settings are handled in the filename. See how [here](#custom-split-image-settings). +- To create split images, it is recommended to use AutoSplit's Take Screenshot button for accuracy. However, images can be created using any method including Print Screen and [Snipping Tool](https://support.microsoft.com/en-us/help/4027213/windows-10-open-snipping-tool-and-take-a-screenshot). + +#### Capture Region + +- This is the region that your split images are compared to. Usually, this is going to be the full game screen. +- Click "Select Region". +- Click and drag to form a rectangle over the region you want to capture. +- Adjust the x, y, width, and height of the capture region manually to make adjustments as needed. +- If you want to align your capture region by using a reference image, click "Align Region". +- You can freely move the window that the program is capturing, but resizing the window will cause the capture region to change. +- Once you are happy with your capture region, you may unselect Live Capture Region to decrease CPU usage if you wish. +- You can save a screenshot of the capture region to your split image folder using the Take Screenshot button. + +#### Avg. FPS + +- Calculates the average comparison rate of the capture region to split images. This value will likely be much higher than needed, so it is highly recommended to limit your FPS depending on the frame rate of the game you are capturing. + +### Settings + +#### Comparison Method + +- There are three comparison methods to choose from: L2 Norm, Histograms, and Perceptual Hash (or pHash). + - L2 Norm: This method should be fine to use for most cases. It finds the difference between each pixel, squares it, sums it over the entire image and takes the square root. This is very fast but is a problem if your image is high frequency. Any translational movement or rotation can cause similarity to be very different. + - Histograms: An explanation on Histograms comparison can be found [here](https://mpatacchiola.github.io/blog/2016/11/12/the-simplest-classifier-histogram-intersection.html). This is a great method to use if you are using several masked images. + > This algorithm is particular reliable when the colour is a strong predictor of the object identity. The histogram intersection [...] is robust to occluding objects in the foreground. + - Perceptual Hash: An explanation on pHash comparison can be found [here](http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html). It is highly recommended to NOT use pHash if you use masked images, or it'll be very inaccurate. + +#### Capture Method + + +##### Windows + +- **Windows Graphics Capture** (fast, most compatible, capped at 60fps) + Only available in Windows 10.0.17134 and up. + Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled. + Allows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows. + Adds a yellow border on Windows 10 (not on Windows 11). + Caps at around 60 FPS. +- **BitBlt** (fastest, least compatible) + The best option when compatible. But it cannot properly record OpenGL, Hardware Accelerated or Exclusive Fullscreen windows. + The smaller the selected region, the more efficient it is. +- **Direct3D Desktop Duplication** (slower, bound to display) + Duplicates the desktop using Direct3D. + It can record OpenGL and Hardware Accelerated windows. + About 10-15x slower than BitBlt. Not affected by window size. + Overlapping windows will show up and can't record across displays. + This option may not be available for hybrid GPU laptops, see [D3DDD-Note-Laptops.md](/docs/D3DDD-Note-Laptops.md) for a solution. +- **Force Full Content Rendering** (very slow, can affect rendering) + Uses BitBlt behind the scene, but passes a special flag to PrintWindow to force rendering the entire desktop. + About 10-15x slower than BitBlt based on original window size and can mess up some applications' rendering pipelines. + +##### Linux + +- **X11 XCB** (fast, requires XCB) + Uses the XCB library to take screenshots of the X11 server. +- **Scrot** (very slow, may leave files) + Uses Scrot (SCReenshOT) to take screenshots. + Leaves behind a screenshot file if interrupted. + + "scrot" must be installed: `sudo apt-get install scrot` + +##### All platforms + +- **Video Capture Device** + Uses a Video Capture Device, like a webcam, virtual cam, or capture card. + +#### Capture Device + +Select the Video Capture Device that you wanna use if selecting the `Video Capture Device` Capture Method. + + +#### Show Live Similarity + +- Displays the live similarity between the capture region and the current split image. This number is between 0 and 1, with 1 being a perfect match. + +#### Show Highest Similarity + +- Shows the highest similarity between the capture region and current split image. + +#### Current Similarity Threshold + +- When the live similarity goes above this value, the program hits your split hotkey and moves to the next split image. + +#### Default Similarity Threshold + +- This value will be set as the threshold for an image if there is no custom threshold set for that image. + +#### Default Delay Time + +- Time in milliseconds that the program waits before hitting the split hotkey for that specific split if there is no custom Delay Time set for that image. + +#### Default Pause Time + +- Time in seconds that the program stops comparison after a split if there is no custom Pause Time set for that image. Useful for if you have two of the same split images in a row and want to avoid double-splitting. Also useful for reducing CPU usage. + +#### Dummy splits when undoing / skipping + +AutoSplit will group dummy splits together with a real split when undoing/skipping. This basically allows you to tie one or more dummy splits to a real split to keep it as in sync as possible with the real splits in LiveSplit/wsplit. If they are out of sync, you can always use "Previous Image" and "Next Image". + +Examples: +Given these splits: 1 dummy, 2 normal, 3 dummy, 4 dummy, 5 normal, 6 normal. + +In this situation you would have only 3 splits in LiveSplit/wsplit (even though there are 6 split images, only 3 are "real" splits). This basically results in 3 groups of splits: 1st split is images 1 and 2. 2nd split is images 3, 4 and 5. 3rd split is image 6. + +- If you are in the 1st or 2nd image and press the skip key, it will end up on the 3rd image +- If you are in the 3rd, 4th or 5th image and press the undo key, it will end up on the 2nd image +- If you are in the 3rd, 4th or 5th image and press the skip key, it will end up on the 6th image +- If you are in the 6th image and press the undo key, it will end up on the 5th image + +#### Loop last Split Image to first Split Image + +If this option is enabled, when the last split meets the threshold and splits, AutoSplit will loop back to the first split image and continue comparisons. +If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. +This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. + +#### Start also Resets + +If this option is enabled, a "Start" command (ie: from the Start Image) will also send the "Reset" command. This is useful if you want to automatically restart your timer using the Start Image. Since AutoSplit won't be running and won't be checking for the Reset Image. + +Having the reset image check be active at all time would be a better, more organic solution in the future. But that is dependent on migrating to an observer pattern () and being able to reload all images. + +#### Enable auto Reset Image + +This option is mainly meant to be toggled with the `Toggle auto Reset Image` hotkey. You can enable it to temporarily disable the Reset Image if you make a mistake in your run that would cause the Reset Image to trigger. Like exiting back to the game's menu (aka Save&Quit). + +### Custom Split Image Settings + +- Each split image can have different thresholds, pause times, delay split times, loop amounts, and can be flagged. +- These settings are handled in the image's filename. +- **Custom thresholds** are place between parenthesis `()` in the filename. This value will override the default threshold. +- **Custom pause times** are placed between square brackets `[]` in the filename. This value will override the default pause time. +- **Custom delay times** are placed between hash signs `##` in the filename. Note that these are in milliseconds. For example, a 10 second split delay would be `#10000#`. You cannot skip or undo splits during split delays. +- A different **comparison method** can be specified with their 0-base index between carets `^^`: + - `^0^`: L2 Norm + - `^1^`: Histogram + - `^2^`: Perceptual Hash +- **Image loop** amounts are placed between at symbols `@@` in the filename. For example, a specific image that you want to split 5 times in a row would be `@5@`. The current loop # is conveniently located beneath the current split image. +- **Flags** are placed between curly brackets `{}` in the filename. Multiple flags are placed in the same set of curly brackets. Current available flags: + - `{d}` **dummy split image**. When matched, it moves to the next image without hitting your split hotkey. + - `{b}` split when **similarity goes below** the threshold rather than above. When a split image filename has this flag, the split image similarity will go above the threshold, do nothing, and then split the next time the similarity goes below the threshold. + - `{p}` **pause flag**. When a split image filename has this flag, it will hit your pause hotkey rather than your split hokey. +- Filename examples: + - `001_SplitName_(0.9)_[10].png` is a split image with a threshold of 0.9 and a pause time of 10 seconds. + - `002_SplitName_(0.9)_[10]_{d}.png` is the second split image with a threshold of 0.9, pause time of 10, and is a dummy split. + - `003_SplitName_(0.85)_[20]_#3500#.png` is the third split image with a threshold of 0.85, pause time of 20 and has a delay split time of 3.5 seconds. + - `004_SplitName_(0.9)_[10]_#3500#_@3@_{b}.png` is the fourth split image with a threshold of 0.9, pause time of 10 seconds, delay split time of 3.5 seconds, will loop 3 times, and will split when similarity is below the threshold rather than above. + +## Special images + +### How to Create a Masked Image + +Masked images are very useful if only a certain part of the capture region is consistent (for example, consistent text on the screen, but the background is always different). Histogram or L2 norm comparison is recommended if you use any masked images. It is highly recommended that you do NOT use pHash comparison if you use any masked images, or it'll be very inaccurate. + +The best way to create a masked image is to set your capture region as the entire game screen, take a screenshot, and use a program like [paint.net](https://www.getpaint.net/) to "erase" (make transparent) everything you don't want the program to compare. More on creating images with transparency using paint.net can be found in [this tutorial](https://www.youtube.com/watch?v=v53kkUYFVn8). For visualization, here is what the capture region compared to a masked split image looks like if you would want to split on "Shine Get!" text in Super Mario Sunshine: + +![Mask Example](/docs/mask_example_image.png) + +### Reset Image + +You can have one (and only one) image with the keyword `reset` in its name. AutoSplit will press the reset button when it finds this image. This image will only be used for resets and it will not be tied to any split. You can set a threshold and pause time for it. The pause time is the amount of seconds AutoSplit will wait before checking for the Reset Image once the run starts. For example: `Reset_(0.95)_[10].png`. + +### Start Image + +The Start Image is similar to the Reset Image. You can only have one Start Image with the keyword `start_auto_splitter`.You can reload the image using the "`Reload Start Image`" button. The pause time is the amount of seconds AutoSplit will wait before starting comparisons of the first split image. Delay times will be used to delay starting your timer after the threshold is met. + +### Profiles + + +- Profiles use the extension `.toml`. Profiles can be saved and loaded by using `File -> Save Profile As...` and `File -> Load Profile`. +- The profile contains all of your settings, including information about the capture region. +- You can save multiple profiles, which is useful if you speedrun multiple games. +- If you change your display setup (like using a new monitor, or upgrading to Windows 11), you may need to readjust or reselect your Capture Region. + +## Timer Integration Tutorial + +### Timer Global Hotkeys + +- Click "Set Hotkey" on each hotkey to set the hotkeys to AutoSplit. The Start / Split hotkey and Pause hotkey must be the same as the one used in your preferred timer program in order for the splitting/pausing to work properly. +- Make sure that Global Hotkeys are enabled in your speedrun timer. +- All of these actions can also be handled by their corresponding buttons. +- Note that pressing your Pause Hotkey does not serve any function in AutoSplit itself and is strictly used for the Pause flag. + +#### LiveSplit Integration + +See the [usage instructions](https://github.com/Toufool/LiveSplit.AutoSplitIntegration#openingclosing-autosplit). diff --git a/src/menu_bar.py b/src/menu_bar.py index 810713f2..c47e880d 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -95,7 +95,7 @@ def open_update_checker(autosplit: "AutoSplit", latest_version: str, *, check_on def view_help(): - webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#tutorial") + webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}/blob/master/docs/tutorial.md") class __CheckForUpdatesThread(QtCore.QThread): # noqa: N801 # Private class From a1a26abd18dd34087b4008ad477016f55b187cc7 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 14:41:54 -0500 Subject: [PATCH 25/28] Remove outdated sonar checks from archived fork --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index a19f4627..19f5ee26 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@ # LiveSplit AutoSplit [![CodeQL](/../../actions/workflows/codeql-analysis.yml/badge.svg)](/../../actions/workflows/codeql-analysis.yml) [![Lint and build](/../../actions/workflows/lint-and-build.yml/badge.svg)](/../../actions/workflows/lint-and-build.yml) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_AutoSplit&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=Avasam_AutoSplit) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_AutoSplit&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=Avasam_AutoSplit) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_AutoSplit&metric=security_rating)](https://sonarcloud.io/dashboard?id=Avasam_AutoSplit) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Avasam_AutoSplit&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Avasam_AutoSplit) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Avasam_AutoSplit&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Avasam_AutoSplit) [![SemVer](https://badgen.net/badge/_/SemVer%20compliant/grey?label)](https://semver.org/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![autopep8](https://badgen.net/badge/code%20style/autopep8/blue)](https://github.com/hhatto/autopep8) From cd3d88d566683091780fb94f204c346b758fbb44 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 9 Mar 2024 17:02:39 -0500 Subject: [PATCH 26/28] Fix error w/ _update_checker_widget_signal_slot --- src/AutoSplit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 97abae5b..7302d02c 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -185,7 +185,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.after_setting_hotkey_signal.connect(lambda: after_setting_hotkey(self)) self.start_auto_splitter_signal.connect(self.__auto_splitter) - def _update_checker_widget_signal_slot(latest_version: str, *, check_on_open: bool): + def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool): # noqa: FBT001 return open_update_checker(self, latest_version, check_on_open=check_on_open) self.update_checker_widget_signal.connect(_update_checker_widget_signal_slot) From c4b0c63835bcf39e0057cec67d2cf0be2abb6d74 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 20 Oct 2024 18:05:50 -0400 Subject: [PATCH 27/28] Update non-python files from dev --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/lint-and-build.yml | 43 +++--- .github/workflows/printenv.yml | 2 +- .pre-commit-config.yaml | 19 +-- .vscode/extensions.json | 7 +- .vscode/settings.json | 22 ++- README.md | 28 ++-- docs/build instructions.md | 5 +- docs/tutorial.md | 74 ++++++++- pyproject.toml | 154 +------------------ res/settings.ui | 121 +++++++++++++-- ruff.toml | 162 ++++++++++++++++++++ scripts/build.ps1 | 3 +- scripts/install.ps1 | 32 +--- scripts/lint.ps1 | 17 +- scripts/python_build_from_source_linux.bash | 12 +- scripts/requirements-dev.txt | 5 +- scripts/requirements.txt | 25 ++- typings/scipy/__init__.pyi | 2 +- typings/scipy/fft/_realtransforms.pyi | 20 +-- 20 files changed, 458 insertions(+), 297 deletions(-) create mode 100644 ruff.toml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 85ffffdf..66f0c27c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 85f116ab..414cc845 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -46,16 +46,12 @@ jobs: # Ruff is version and platform sensible matrix: os: [windows-latest, ubuntu-22.04] - python-version: ["3.10", "3.11", "3.12"] - # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 - exclude: - - os: ubuntu-22.04 - python-version: "3.12" + python-version: ["3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -70,25 +66,30 @@ jobs: # Pyright is version and platform sensible matrix: os: [windows-latest, ubuntu-22.04] - python-version: ["3.10", "3.11", "3.12"] - # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 - exclude: - - os: ubuntu-22.04 - python-version: "3.12" + python-version: ["3.11", "3.12"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" cache-dependency-path: "scripts/requirements*.txt" - run: scripts/install.ps1 shell: pwsh + - name: Get pyright version + id: pyright_version + run: | + PYRIGHT_VERSION=$(grep '$pyrightVersion = ' 'scripts/lint.ps1' | cut -d "#" -f 1 | cut -d = -f 2 | tr -d " '") + echo pyright version: "${PYRIGHT_VERSION}" + echo PYRIGHT_VERSION="${PYRIGHT_VERSION}" >> "${GITHUB_OUTPUT}" + shell: bash - name: Analysing the code with Pyright - uses: jakebailey/pyright-action@v1 + uses: jakebailey/pyright-action@v2 with: + version: ${{ steps.pyright_version.outputs.PYRIGHT_VERSION }} + extra-args: --threads working-directory: src/ python-version: ${{ matrix.python-version }} Build: @@ -99,15 +100,11 @@ jobs: matrix: os: [windows-latest, ubuntu-22.04] python-version: ["3.11", "3.12"] - # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 - exclude: - - os: ubuntu-22.04 - python-version: "3.12" steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -117,13 +114,13 @@ jobs: - run: scripts/build.ps1 shell: pwsh - name: Upload Build Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: AutoSplit for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: dist/AutoSplit* if-no-files-found: error - name: Upload Build logs - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Build logs for ${{ matrix.os }} (Python ${{ matrix.python-version }}) path: | diff --git a/.github/workflows/printenv.yml b/.github/workflows/printenv.yml index ed1adcbf..14aeb4f3 100644 --- a/.github/workflows/printenv.yml +++ b/.github/workflows/printenv.yml @@ -22,5 +22,5 @@ jobs: runs-on: windows-latest steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 - run: printenv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71be36f4..e901070b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: pretty-format-json exclude: ".vscode/.*" # Exclude jsonc @@ -12,24 +12,19 @@ repos: args: [--fix=lf] - id: check-case-conflict - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.12.0 + rev: v2.14.0 hooks: + - id: pretty-format-yaml + args: [--autofix, --indent, "2", --offset, "2", --preserve-quotes, --line-width, "100"] - id: pretty-format-ini args: [--autofix] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.3.2" # Must match requirements-dev.txt + rev: v0.6.9 # Must match requirements-dev.txt hooks: - id: ruff args: [--fix] - - repo: https://github.com/hhatto/autopep8 - rev: "v2.0.4" # Must match requirements-dev.txt - hooks: - - id: autopep8 - - repo: https://github.com/asottile/add-trailing-comma - rev: v3.1.0 # Must match requirements-dev.txt - hooks: - - id: add-trailing-comma + - id: ruff-format ci: autoupdate_branch: dev - autoupdate_schedule: monthly + autoupdate_schedule: quarterly diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 790ee33f..ecd26dfc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,11 +1,10 @@ // Keep in alphabetical order { "recommendations": [ + "charliermarsh.ruff", "davidanson.vscode-markdownlint", "eamodio.gitlens", - "emeraldwalk.runonsave", "github.vscode-github-actions", - "ms-python.autopep8", "ms-python.python", "ms-python.vscode-pylance", "ms-vscode.powershell", @@ -32,11 +31,11 @@ // Don't recommend to autoinstall // // // Use Ruff instead + "ms-python.autopep8", + "ms-python.black-formatter", "ms-python.flake8", "ms-python.isort", "ms-python.pylint", - // We use autopep8 - "ms-python.black-formatter", // This is a Git project "johnstoncode.svn-scm", // Prefer using VSCode itself as a text editor diff --git a/.vscode/settings.json b/.vscode/settings.json index 815e9679..d3e7500a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "editor.rulers": [ 80, - 120 + 100 ], "[git-commit]": { "editor.rulers": [ @@ -25,14 +25,6 @@ // Let dedicated linter (Ruff) organize imports "source.organizeImports": "never" }, - "emeraldwalk.runonsave": { - "commands": [ - { - "match": "\\.pyi?", - "cmd": "add-trailing-comma ${file}" - }, - ] - }, "files.associations": { ".flake8": "properties", "*.qrc": "xml", @@ -62,16 +54,19 @@ "[json][jsonc]": { "editor.defaultFormatter": "vscode.json-language-features", }, + "[yaml]": { + "editor.defaultFormatter": "redhat.vscode-yaml" + }, + "yaml.format.printWidth": 100, "[python]": { - // Ruff as a formatter doesn't fully satisfy our needs yet: https://github.com/astral-sh/ruff/discussions/7310 - "editor.defaultFormatter": "ms-python.autopep8", + "editor.defaultFormatter": "charliermarsh.ruff", "editor.tabSize": 4, "editor.rulers": [ 72, // PEP8-17 docstrings // 79, // PEP8-17 default max // 88, // Black default // 99, // PEP8-17 acceptable max - 120, // Our hard rule + 100, // Our hard rule ], }, "mypy-type-checker.importStrategy": "fromEnvironment", @@ -87,6 +82,9 @@ ], "python.analysis.diagnosticMode": "workspace", "ruff.importStrategy": "fromEnvironment", + "ruff.enable": true, + "ruff.fixAll": true, + "ruff.organizeImports": true, // Use the Ruff extension instead "isort.check": false, "powershell.codeFormatting.pipelineIndentationStyle": "IncreaseIndentationForFirstPipeline", diff --git a/README.md b/README.md index 19f5ee26..b22efe9b 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ # LiveSplit AutoSplit [![CodeQL](/../../actions/workflows/codeql-analysis.yml/badge.svg)](/../../actions/workflows/codeql-analysis.yml) [![Lint and build](/../../actions/workflows/lint-and-build.yml/badge.svg)](/../../actions/workflows/lint-and-build.yml) [![SemVer](https://badgen.net/badge/_/SemVer%20compliant/grey?label)](https://semver.org/) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![autopep8](https://badgen.net/badge/code%20style/autopep8/blue)](https://github.com/hhatto/autopep8) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://docs.astral.sh/ruff/linter/) +[![Ruff format](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/format.json)](https://docs.astral.sh/ruff/formatter/) [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/) [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) Easy to use image comparison based auto splitter for speedrunning on console or PC. -This program can be used to automatically start, split, and reset your preferred speedrun timer by comparing images to a capture region. This allows you to focus more on your speedrun and less on managing your timer. It also improves the accuracy of your splits. It can be used in tandem with any speedrun timer that accepts hotkeys (LiveSplit, wsplit, etc.), and can be integrated with LiveSplit. +This program can be used to automatically start, split, and reset your preferred speedrun timer by comparing images to a capture region. This allows you to focus more on your speedrun and less on managing your timer. It also improves the accuracy of your splits. It can be used in tandem with any speedrun timer that accepts hotkeys (LiveSplit, WSplit, etc.), and can be integrated with LiveSplit. ![Example](/docs/2.0.0_gif.gif) @@ -43,10 +43,11 @@ To understand how to use AutoSplit and how it works in-depth, please read the [t ### Compatibility - Windows 10 and 11. -- Linux (still in early development) +- Linux (still in early development) + - Should work on Ubuntu 20.04+ (Only tested on Ubuntu 22.04) - Wayland is not currently supported - WSL2/WSLg requires an additional Desktop Environment, external X11 server, and/or systemd -- Python 3.10+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). +- Python 3.11+ (Not required for normal use. Refer to the [build instructions](/docs/build%20instructions.md) if you'd like run the application directly in Python). ## Timer Integration @@ -85,18 +86,24 @@ See [CONTRIBUTING.md](/docs/CONTRIBUTING.md) for our contributing standards. Refer to the [build instructions](/docs/build%20instructions.md) if you're interested in building the application yourself or running it in Python. Not a developer? You can still help through the following methods: - + - Donating (see link below) -- [Upvoting feature requests](../../issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement) you are interested in +- [Upvoting 👍 feature requests](../../issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement) you are interested in - Sharing AutoSplit with other speedrunners -- Upvoting the following upstream issues in libraries and tools we use: +- Upvoting 👍 the following upstream issues in libraries and tools we use: - - + - - + - + - - + - - - - - - + - + - + - + - - - - @@ -108,6 +115,7 @@ Not a developer? You can still help through the following methods: - - - + - ## Credits diff --git a/docs/build instructions.md b/docs/build instructions.md index 55f22a76..0a157923 100644 --- a/docs/build instructions.md +++ b/docs/build instructions.md @@ -13,10 +13,11 @@ ### All platforms -- [Python](https://www.python.org/downloads/) 3.10+. +- [Python](https://www.python.org/downloads/) 3.11+. - [Node](https://nodejs.org) is optional, but required for complete linting. - Alternatively you can install the [pyright python wrapper](https://pypi.org/project/pyright/) which has a bit of an overhead delay. -- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) is used to run all the scripts +- [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) is used to run all the scripts. + - This is needed even for Windows, as the bundled PowerShell 5.1 is too old. - [VSCode](https://code.visualstudio.com/Download) is not required, but highly recommended. - Everything already configured in the workspace, including Run (F5) and Build (Ctrl+Shift+B) commands, default shell, and recommended extensions. - [PyCharm](https://www.jetbrains.com/pycharm/) is also a good Python IDE, but nothing is configured. If you are a PyCharm user, feel free to open a PR with all necessary workspace configurations! diff --git a/docs/tutorial.md b/docs/tutorial.md index 9dffdef4..eb9b669b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -43,7 +43,6 @@ - **Windows Graphics Capture** (fast, most compatible, capped at 60fps) Only available in Windows 10.0.17134 and up. - Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled. Allows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows. Adds a yellow border on Windows 10 (not on Windows 11). Caps at around 60 FPS. @@ -53,7 +52,8 @@ - **Direct3D Desktop Duplication** (slower, bound to display) Duplicates the desktop using Direct3D. It can record OpenGL and Hardware Accelerated windows. - About 10-15x slower than BitBlt. Not affected by window size. + Up to 15x slower than BitBlt for tiny regions. Not affected by window size. + Limited by the target window and monitor's refresh rate. Overlapping windows will show up and can't record across displays. This option may not be available for hybrid GPU laptops, see [D3DDD-Note-Laptops.md](/docs/D3DDD-Note-Laptops.md) for a solution. - **Force Full Content Rendering** (very slow, can affect rendering) @@ -168,6 +168,76 @@ You can have one (and only one) image with the keyword `reset` in its name. Auto The Start Image is similar to the Reset Image. You can only have one Start Image with the keyword `start_auto_splitter`.You can reload the image using the "`Reload Start Image`" button. The pause time is the amount of seconds AutoSplit will wait before starting comparisons of the first split image. Delay times will be used to delay starting your timer after the threshold is met. +### Text Recognition / Optical Character Recognition (OCR) ⚠️EXPERIMENTAL⚠️ + +You can use text recognition as an alternative comparison method. + +#### Tesseract install + +First you need to install tesseract and include it in your system or user environment variables. + +- See for installation instruction on all platforms. +- For Windows: + 1. You can go directly to to find the installer. + 2. If you change the "Destination Folder" during install, then you'll also need to add it to your `PATH` environment variable. + +#### Usage + +To use this feature you need to place a text file (`.txt`) in your splits folder instead of an image file. + +An example file name and content could look like this: + +Filename: `001_start_auto_splitter.txt` + +Content: + +```toml +texts = ["complete any 2 encounters"] +left = 275 +right = 540 +top = 70 +bottom = 95 +methods = [0] +fps_limit = 1 +``` + +The `texts` field is an array and can take more than one text to look for: + +```toml +texts = ["look for me", "or this text"] +``` + +Note: for now we only use lowercase letters in the comparison. All uppercase letters are converted to lowercase before the comparison. + +The rectangle coordinates where the text you are looking for is expected to appear in the image are configured as follows: + +```toml +left = 275 +right = 540 +top = 70 +bottom = 95 +``` + +If you're used to working in corner coordinates, you can think of `top_left = [left, top]` and `bottom_right = [right, bottom]`. + +Currently there are two comparison methods: + +- `0` - uses the Levenshtein distance (the default) +- `1` - checks if the OCR text contains the searched text (results in matches of either `0.0` or `1.0`) + +If you only want a perfect full match, use "Levenshtein" with a threshold of `(1.0)` on your file name. + +You can also chain multiple comparison methods using the array notation: + +```toml +methods = [1, 0] +``` + +The methods are then checked in the order you defined and the best match upon them wins. + +Note: This method can cause high CPU usage at the standard comparison FPS. You should therefor limit the comparison FPS when you use this method to 1 or 2 FPS using the `fps_limit` option. +The size of the selected rectangle can also impact the CPU load (bigger = more CPU load). + ### Profiles diff --git a/pyproject.toml b/pyproject.toml index 41b4e3a5..ef243b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,159 +1,7 @@ -# https://docs.astral.sh/ruff/configuration/ -[tool.ruff] -target-version = "py310" -line-length = 120 -preview = true - -[tool.ruff.lint] -select = ["ALL"] -# https://docs.astral.sh/ruff/rules/ -ignore = [ - ### - # Not needed or wanted - ### - "D1", # pydocstyle Missing doctring - "D401", # pydocstyle: non-imperative-mood - "EM", # flake8-errmsg - # This is often something we can't control: https://github.com/astral-sh/ruff/issues/9497 - # Also false-positive with positional-only arguments: https://github.com/astral-sh/ruff/issues/3247 - "FBT003", # flake8-boolean-trap: boolean-positional-value-in-call - "INP", # flake8-no-pep420 - "ISC003", # flake8-implicit-str-concat: explicit-string-concatenation - # Short messages are still considered "long" messages - "TRY003", # tryceratops : raise-vanilla-args - # Don't remove commented code, also too inconsistant - "ERA001", # eradicate: commented-out-code - # contextlib.suppress is roughly 3x slower than try/except - "SIM105", # flake8-simplify: use-contextlib-suppress - # Negative performance impact - "UP038", # non-pep604-isinstance - # Checked by type-checker (pyright) - "ANN", # flake-annotations - "PGH003", # blanket-type-ignore - "TCH", # flake8-type-checking - # Already shown by Pylance, checked by pyright, and can be caused by overloads. - "ARG002", # Unused method argument - # We want D213: multi-line-summary-second-line and D211: no-blank-line-before-class - "D203", # pydocstyle: one-blank-line-before-class - "D212", # pydocstyle: multi-line-summary-first-line - # Allow differentiating between broken (FIXME) and to be done/added/completed (TODO) - "TD001", # flake8-todos: invalid-todo-tag - - ### - # These should be warnings (https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774) - ### - "FIX", # flake8-fixme - # Not all TODOs are worth an issue, this would be better as a warning - "TD003", # flake8-todos: missing-todo-link - - # False-positives - "TCH004", # https://github.com/astral-sh/ruff/issues/3821 - - ### - # Specific to this project - ### - "D205", # Not all docstrings have a short description + desrciption - # We have some Pascal case module names - "N999", # pep8-naming: Invalid module name - # Print are used as debug logs - "T20", # flake8-print - # This is a relatively small, low contributors project. Git blame suffice. - "TD002", # missing-todo-author - # Python 3.11, introduced "zero cost" exception handling - "PERF203", # try-except-in-loop - - ### FIXME/TODO: I'd normally set them as temporarily warnings, but no warnings in Ruff yet: - ### https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774): - "CPY001", # flake8-copyright - "PTH", # flake8-use-pathlib - # Ignore until linux support - "EXE", # flake8-executable -] - -[tool.ruff.lint.per-file-ignores] -"typings/**/*.pyi" = [ - "F811", # Re-exports false positives - "F821", # https://github.com/astral-sh/ruff/issues/3011 - # The following can't be controlled for external libraries: - "A", # Shadowing builtin names - "FBT", # flake8-boolean-trap - "ICN001", # unconventional-import-alias - "N8", # Naming conventions - "PLC2701", # Private name import - "PLR0904", # Too many public methods - "PLR0913", # Argument count - "PLR0917", # Too many positional arguments - "PLW3201", # misspelled dunder method name - "PYI042", # CamelCase TypeAlias -] - -# https://docs.astral.sh/ruff/settings/#flake8-implicit-str-concat -[tool.ruff.lint.flake8-implicit-str-concat] -allow-multiline = false - -# https://docs.astral.sh/ruff/settings/#isort -[tool.ruff.lint.isort] -combine-as-imports = true -split-on-trailing-comma = false -# Unlike isort, Ruff only counts relative imports as local-folder by default for know. -# https://github.com/astral-sh/ruff/issues/3115 -known-local-folder = [ - "AutoControlledThread", - "AutoSplit", - "AutoSplitImage", - "capture_method", - "compare", - "error_messages", - "gen", - "hotkeys", - "menu_bar", - "region_selection", - "split_parser", - "user_profile", - "utils", -] - -# https://docs.astral.sh/ruff/settings/#mccabe -[tool.ruff.lint.mccabe] -# Hard limit, arbitrary to 4 bytes -max-complexity = 31 -# Arbitrary to 2 bytes, same as SonarLint -# max-complexity = 15 - -[tool.ruff.lint.pylint] -# Arbitrary to 1 byte, same as SonarLint -max-args = 7 -# At least same as max-complexity -max-branches = 15 - -# https://github.com/hhatto/autopep8#usage -# https://github.com/hhatto/autopep8#more-advanced-usage -[tool.autopep8] -max_line_length = 120 -aggressive = 3 -exclude = ".venv/*,src/gen/*" -ignore = [ - "E124", # Closing bracket may not match multi-line method invocation style (enforced by add-trailing-comma) - "E70", # Allow ... on same line as def - "E721", # Breaks when needing an exact type - # Autofixed by Ruff - # Check for the "Fix" flag https://docs.astral.sh/ruff/rules/#pycodestyle-e-w - "E2", # Whitespace - "E3", # Blank lines - "E703", # useless-semicolon - "E71", # Statement (comparisons) - "E731", # lambda-assignment - "W29", # Whitespace warning - "W605", # invalid-escape-sequence - # Autofixed by other Ruff rules - "E401", # I001: unsorted-imports - "W690", # UP: pyupgrade -] - # https://github.com/microsoft/pyright/blob/main/docs/configuration.md#sample-pyprojecttoml-file [tool.pyright] typeCheckingMode = "strict" -pythonVersion = "3.10" +pythonVersion = "3.11" # Prefer `pyright: ignore` enableTypeIgnoreComments = false diff --git a/res/settings.ui b/res/settings.ui index bcf28cb7..f49deecb 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -7,20 +7,20 @@ 0 0 - 285 - 294 + 284 + 334 - 285 - 294 + 284 + 334 - 285 - 294 + 284 + 334 @@ -41,7 +41,7 @@ -3 -3 291 - 301 + 341 @@ -63,6 +63,9 @@ true + + (Defaults to Split Image Folder) + @@ -85,7 +88,7 @@ 10 40 - 141 + 261 24 @@ -104,7 +107,7 @@ 10 70 - 151 + 261 16 @@ -155,7 +158,7 @@ 10 120 - 151 + 261 16 @@ -207,7 +210,7 @@ 10 220 - 181 + 261 24 @@ -221,6 +224,102 @@ false + + + + 10 + 280 + 131 + 24 + + + + Screenshot on Split + + + false + + + + + + 10 + 240 + 131 + 24 + + + + Screenshot on Start + + + false + + + + + + 10 + 260 + 131 + 24 + + + + Screenshot on Reset + + + false + + + + + + 140 + 260 + 131 + 24 + + + + Screenshot on Skip + + + false + + + + + + 140 + 240 + 131 + 24 + + + + Screenshot on Undo + + + false + + + + + + 140 + 280 + 131 + 24 + + + + Screenshot on Pause + + + false + + diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..bbefddaf --- /dev/null +++ b/ruff.toml @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: MIT +# Copyright 2024 Beslogic Inc. + +# The source skeleton for this configuration can be found at +# https://github.com/BesLogic/shared-configs/blob/main/ruff.toml +# Modifications to this file that are not project-specific should also be done upstream. +# These configs are incompatible with ruff<0.5.7 + +# https://docs.astral.sh/ruff/configuration/ +target-version = "py311" # Change this to the oldest supported version by your application +line-length = 100 +preview = true + +[format] +docstring-code-format = true + +[lint] +select = ["ALL"] +# https://docs.astral.sh/ruff/rules/ +ignore = [ + ### + # Not needed or wanted + ### + "D1", # pydocstyle Missing doctring + "D401", # pydocstyle: non-imperative-mood + "EM", # flake8-errmsg + "EXE", # flake8-executable + # This is often something we can't control: https://github.com/astral-sh/ruff/issues/9497 + # Also false-positive with positional-only arguments: https://github.com/astral-sh/ruff/issues/3247 + "FBT003", # flake8-boolean-trap: boolean-positional-value-in-call + "INP", # flake8-no-pep420 + "ISC003", # flake8-implicit-str-concat: explicit-string-concatenation + # Short messages are still considered "long" messages + "TRY003", # tryceratops : raise-vanilla-args + # Don't remove commented code, also too inconsistant + "ERA001", # eradicate: commented-out-code + # contextlib.suppress is roughly 3x slower than try/except + "SIM105", # flake8-simplify: use-contextlib-suppress + # Negative performance impact and more verbose https://github.com/astral-sh/ruff/issues/7871 + "UP038", # non-pep604-isinstance + # deprecated and is actually slower for cases relevant to unpacking: https://github.com/astral-sh/ruff/issues/12754 + "UP027", # unpacked-list-comprehension + # Checked by type-checker (pyright/mypy) + "ANN", # flake-annotations + "PGH003", # blanket-type-ignore + "TCH", # flake8-type-checking + # Already shown by Pylance, checked by pyright, and can be caused by overloads. + "ARG002", # Unused method argument + # We want D213: multi-line-summary-second-line and D211: no-blank-line-before-class + "D203", # pydocstyle: one-blank-line-before-class + "D212", # pydocstyle: multi-line-summary-first-line + # Allow differentiating between broken (FIXME) and to be done/added/completed (TODO) + "TD001", # flake8-todos: invalid-todo-tag + + ### + # These should be warnings (https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774) + ### + "FIX", # flake8-fixme + # Not all TODOs are worth an issue, this would be better as a warning + "TD003", # flake8-todos: missing-todo-link + + # False-positives + "TCH004", # https://github.com/astral-sh/ruff/issues/3821 + + ### + # Conflict with formatter (you can remove this section if you don't use Ruff as a formatter) + ### + "COM812", # missing-trailing-comma + "ISC001", # single-line-implicit-string-concatenation + "RUF028", # invalid-formatter-suppression-comment, Is meant for the formatter, but false-positives + + ### + # Rules about missing special documentation. Up to you if you wanna enable these, you must also disable D406, D407 + ### + "DOC201", # docstring-missing-returns + "DOC402", # docstring-missing-yields + "DOC501", # docstring-missing-exception + # "D406", # new-line-after-section-name, conflicts with DOC + # "D407", # dashed-underline-after-section, conflicts with DOC + + ### + # Specific to this project + ### + "D205", # Not all docstrings have a short description + description + # TODO: Consider for more complete doc + "DOC201", # docstring-extraneous-returns + "DOC501", # docstring-missing-exception + # We have some Pascal case module names + "N999", # pep8-naming: Invalid module name + # Print are used as debug logs + "T20", # flake8-print + # This is a relatively small, low contributors project. Git blame suffice. + "TD002", # missing-todo-author + # Python 3.11, introduced "zero cost" exception handling + "PERF203", # try-except-in-loop + + ### + # FIXME/TODO: I'd normally set them as temporarily warnings, but no warnings in Ruff yet: + # https://github.com/astral-sh/ruff/issues/1256 & https://github.com/astral-sh/ruff/issues/1774): + ### + "CPY001", # flake8-copyright + "PTH", # flake8-use-pathlib + # Ignore until linux support + "EXE", # flake8-executable +] + +# https://docs.astral.sh/ruff/settings/#flake8-implicit-str-concat +[lint.flake8-implicit-str-concat] +allow-multiline = false + +# https://docs.astral.sh/ruff/settings/#isort +[lint.isort] +combine-as-imports = true +split-on-trailing-comma = false +# This should be automatically detected in src layout, but somehow pre-commit messes it up +known-first-party = ["gen"] + +# https://docs.astral.sh/ruff/settings/#mccabe +[lint.mccabe] +# Arbitrary to 2 bytes, same as SonarLint +max-complexity = 15 + +[lint.pylint] +# Arbitrary to 1 byte, same as SonarLint +max-args = 7 +# At least same as max-complexity +max-branches = 15 + +[lint.per-file-ignores] +"**/typings/**/*.pyi" = [ + "F811", # Re-exports false positives + # The following can't be controlled for external libraries: + "A", # Shadowing builtin names + "E741", # ambiguous variable name + "F403", # `from . import *` used; unable to detect undefined names + "FBT", # flake8-boolean-trap + "ICN001", # unconventional-import-alias + "N8", # Naming conventions + "PLC2701", # Private name import + "PLR0904", # Too many public methods + "PLR0913", # Argument count + "PLR0917", # Too many positional arguments + "PLW3201", # misspelled dunder method name + "PYI042", # CamelCase TypeAlias + # Stubs can sometimes re-export entire modules. + # Issues with using a star-imported name will be caught by type-checkers. + "F405", # may be undefined, or defined from star imports +] +"src/d3d11.py" = [ + # Following windows API/ctypes like naming conventions + "N801", # invalid-class-name +] + +[lint.flake8-tidy-imports.banned-api] +"cv2.imread".msg = """\ +it doesn't support special characters. \ +Use `cv2.imdecode(np.fromfile(filename, dtype=np.uint8), flags)` instead. +https://github.com/opencv/opencv/issues/4292#issuecomment-2266019697""" +"cv2.imwrite".msg = """\ +it doesn't support special characters. \ +Use `cv2.imencode(os.path.splitext(filename)[1], img)[1].tofile(filename)` instead. +https://github.com/opencv/opencv/issues/4292#issuecomment-2266019697""" diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f6ed7bec..3d416c9f 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -17,8 +17,9 @@ $arguments = @( '--exclude=pytweening', '--exclude=mouseinfo') if ($IsWindows) { + # These are used on Linux $arguments += @( - # Installed by PyAutoGUI, but used by linux + # Installed by PyAutoGUI '--exclude=pyscreeze' # Installed by D3DShot '--exclude=PIL') diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e78af8ce..f7bd1bcd 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -34,8 +34,9 @@ $dev = If ($Env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } If ($IsLinux) { If (-not $Env:GITHUB_JOB -or $Env:GITHUB_JOB -eq 'Build') { sudo apt-get update - # python3-tk for splash screen, npm for pyright, the rest for PySide6 - sudo apt-get install -y python3-pip python3-tk npm libegl1 libxkbcommon0 + # python3-tk for splash screen, libxcb-cursor-dev for QT_QPA_PLATFORM=xcb, the rest for PySide6 + sudo apt-get install -y python3-pip python3-tk libxcb-cursor-dev libegl1 libxkbcommon0 + # having issues with npm for pyright, maybe let users take care of it themselves? (pyright from pip) } } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. @@ -47,33 +48,8 @@ If ($IsLinux) { # These libraries install extra requirements we don't want # Open suggestion for support in requirements files: https://github.com/pypa/pip/issues/9948 & https://github.com/pypa/pip/pull/10837 # PyAutoGUI: We only use it for hotkeys -# D3DShot: Will install Pillow, which we don't use on Windows. -# Even then, PyPI with Pillow>=7.2.0 will install 0.1.3 instead of 0.1.5 -&"$python" -m pip install PyAutoGUI "D3DShot>=0.1.5 ; sys_platform == 'win32'" --no-deps --upgrade +&"$python" -m pip install PyAutoGUI --no-deps --upgrade -# Patch libraries so we don't have to install from git - -If ($IsWindows) { - # Prevent PyAutoGUI and pywinctl from setting Process DPI Awareness, which Qt tries to do then throws warnings about it. - # The unittest workaround significantly increases build time, boot time and build size with PyInstaller. - # https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464 - $libPath = &"$python" -c 'import pyautogui as _; print(_.__path__[0])' - (Get-Content "$libPath/_pyautogui_win.py").replace('ctypes.windll.user32.SetProcessDPIAware()', 'pass') | - Set-Content "$libPath/_pyautogui_win.py" - $libPath = &"$python" -c 'import pymonctl as _; print(_.__path__[0])' - (Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pymonctl_win.py" - $libPath = &"$python" -c 'import pywinbox as _; print(_.__path__[0])' - (Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness(2)', 'pass') | - Set-Content "$libPath/_pywinbox_win.py" -} -# Because Ubuntu 22.04 is forced to use an older version of PySide6, we do a dirty typing patch -# https://bugreports.qt.io/browse/QTBUG-114635 -If ($IsLinux) { - $libPath = &"$python" -c 'import PySide6 as _; print(_.__path__[0])' - (Get-Content "$libPath/QtWidgets.pyi").replace('-> Tuple:', '-> Tuple[str, ...]:') | - Set-Content "$libPath/QtWidgets.pyi" -} # Uninstall optional dependencies if PyAutoGUI or D3DShot was installed outside this script # PyScreeze -> pyscreenshot -> mss deps call SetProcessDpiAwareness, used to be installed on Windows # Pillow, pygetwindow, pymsgbox, pytweening, MouseInfo are picked up by PySide6 diff --git a/scripts/lint.ps1 b/scripts/lint.ps1 index 84dc6f8b..e5dc4a9c 100644 --- a/scripts/lint.ps1 +++ b/scripts/lint.ps1 @@ -3,11 +3,10 @@ Set-Location "$PSScriptRoot/.." $exitCodes = 0 Write-Host "`nRunning formatting..." -autopep8 src/ --recursive --in-place -add-trailing-comma $(git ls-files '**.py*') +ruff format -Write-Host "`nRunning Ruff..." -ruff check . --fix +Write-Host "`nRunning Ruff ..." +ruff check --fix $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Ruff failed ($LastExitCode)" -ForegroundColor Red @@ -16,12 +15,16 @@ else { Write-Host "`Ruff passed" -ForegroundColor Green } -Write-Host "`nRunning Pyright..." -$Env:PYRIGHT_PYTHON_FORCE_VERSION = 'latest' -npx pyright@latest src/ +$pyrightVersion = 'latest' # Change this if latest has issues +Write-Host "`nRunning Pyright $pyrightVersion ..." +$Env:PYRIGHT_PYTHON_FORCE_VERSION = $pyrightVersion +npx -y pyright@$pyrightVersion src/ $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Pyright failed ($LastExitCode)" -ForegroundColor Red + if ($pyrightVersion -eq 'latest') { + npx pyright@latest --version + } } else { Write-Host "`Pyright passed" -ForegroundColor Green diff --git a/scripts/python_build_from_source_linux.bash b/scripts/python_build_from_source_linux.bash index 20c82e75..7e9fb839 100644 --- a/scripts/python_build_from_source_linux.bash +++ b/scripts/python_build_from_source_linux.bash @@ -7,22 +7,22 @@ sudo apt update sudo apt install build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev curl libbz2-dev tk-dev # Download Python binary package: -wget https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tgz +wget https://www.python.org/ftp/python/3.11.10/Python-3.11.10.tgz # Unzip the package: -tar -xzf Python-3.10.13.tgz +tar -xzf Python-3.11.10.tgz # Execute configure script -cd Python-3.10.13 +cd Python-3.11.10 ./configure --enable-optimizations --enable-shared -# Build Python 3.10 +# Build Python 3.11 make -j 2 -# Install Python 3.10 +# Install Python 3.11 sudo make install # Verify the installation -python3.10 -V +python3.11 -V echo "If Python version did not print, you may need to stop active processes" diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 5e242a22..3f11be6e 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -12,12 +12,9 @@ -r requirements.txt # # Linters & Formatters -add-trailing-comma>=3.1.0 # Must match .pre-commit-config.yaml -autopep8>=2.0.4 # Must match .pre-commit-config.yaml -ruff>=0.3.2 # New checks # Must match .pre-commit-config.yaml +ruff>=0.6.9 # Pre-commit fix # Must match .pre-commit-config.yaml # # Types -types-D3DShot ; sys_platform == 'win32' types-keyboard types-psutil types-PyAutoGUI diff --git a/scripts/requirements.txt b/scripts/requirements.txt index e89669ef..69d458a5 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -4,30 +4,43 @@ # # Dependencies: git+https://github.com/boppreh/keyboard.git#egg=keyboard # Fix install on macos and linux-ci https://github.com/boppreh/keyboard/pull/568 -numpy>=1.26 # Python 3.12 support +Levenshtein>=0.25 +# Some modules used by OpenCV are not compiled for Numpy 2 yet on Linux. Error happens on import at runtime +numpy>=1.26,<2.0 # Python 3.12 support opencv-python-headless>=4.9.0.80 # Typing fixes packaging psutil>=5.9.6 # Python 3.12 fixes # PyAutoGUI # See install.ps1 PyWinCtl>=0.0.42 # py.typed # When needed, dev builds can be found at https://download.qt.io/snapshots/ci/pyside/dev?C=M;O=D -PySide6-Essentials>=6.6.0 ; sys_platform == 'win32' # Python 3.12 support -PySide6-Essentials<6.5.1 ; sys_platform == 'linux' # Wayland issue on Ubuntu 22.04 https://bugreports.qt.io/browse/QTBUG-114635 +PySide6-Essentials>=6.6.0 # Python 3.12 support scipy>=1.11.2 # Python 3.12 support -toml +tomli-w>=1.1.0 # Typing fixes typing-extensions>=4.4.0 # @override decorator support + # # Build and compile resources pyinstaller>=5.13 # Python 3.12 support + # # https://peps.python.org/pep-0508/#environment-markers # # Windows-only dependencies: +comtypes<1.4.5 ; sys_platform == 'win32' # https://github.com/pyinstaller/pyinstaller-hooks-contrib/issues/807 pygrabber>=0.2 ; sys_platform == 'win32' # Completed types pywin32>=301 ; sys_platform == 'win32' -winsdk>=1.0.0b10 ; sys_platform == 'win32' # Python 3.12 support -# D3DShot # See install.ps1 +typed-D3DShot[numpy]>=1.0.1 ; sys_platform == 'win32' +winrt-Windows.Foundation>=2.2.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.Capture>=2.3.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.Capture.Interop>=2.3.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.DirectX>=2.3.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.DirectX.Direct3D11>=2.3.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.DirectX.Direct3D11.Interop>=2.3.0 ; sys_platform == 'win32' +winrt-Windows.Graphics>=2.2.0 ; sys_platform == 'win32' # Python 3.13 support +winrt-Windows.Graphics.Imaging>=2.3.0 ; sys_platform == 'win32' # Python 3.13 support + # # Linux-only dependencies PyScreeze ; sys_platform == 'linux' +pillow ; sys_platform == 'linux' # Necessary for PyScreeze. For unknown reasons it's not pulled in on CI python-xlib ; sys_platform == 'linux' diff --git a/typings/scipy/__init__.pyi b/typings/scipy/__init__.pyi index 8e85a093..4b94936a 100644 --- a/typings/scipy/__init__.pyi +++ b/typings/scipy/__init__.pyi @@ -1,4 +1,3 @@ - from numpy.fft import ifft as ifft from numpy.random import rand as rand, randn as randn from scipy import ( @@ -52,4 +51,5 @@ __all__ = [ ] test: PytestTester + def __dir__() -> list[str]: ... diff --git a/typings/scipy/fft/_realtransforms.pyi b/typings/scipy/fft/_realtransforms.pyi index fd68b022..5d4a5c9e 100644 --- a/typings/scipy/fft/_realtransforms.pyi +++ b/typings/scipy/fft/_realtransforms.pyi @@ -1,4 +1,5 @@ -from _typeshed import Incomplete +from typing import Literal + from numpy import float64, generic from numpy.typing import NDArray @@ -15,7 +16,6 @@ def dctn( *, orthogonalize=None, ): ... - def idctn( x, type=2, @@ -26,7 +26,6 @@ def idctn( workers=None, orthogonalize=None, ): ... - def dstn( x, type=2, @@ -37,7 +36,6 @@ def dstn( workers=None, orthogonalize=None, ): ... - def idstn( x, type=2, @@ -48,18 +46,16 @@ def idstn( workers=None, orthogonalize=None, ): ... - def dct( x: NDArray[generic], - type: int = 2, - n: Incomplete | None = None, + type: Literal[1, 2, 3, 4] = 2, + n: int | None = None, axis: int = -1, - norm: Incomplete | None = None, + norm: Literal["backward", "ortho", "forward"] | None = None, overwrite_x: bool = False, - workers: Incomplete | None = None, - orthogonalize: Incomplete | None = None, + workers: int | None = None, + orthogonalize: bool | None = None, ) -> NDArray[float64]: ... - def idct( x, type=2, @@ -70,7 +66,6 @@ def idct( workers=None, orthogonalize=None, ): ... - def dst( x, type=2, @@ -81,7 +76,6 @@ def dst( workers=None, orthogonalize=None, ): ... - def idst( x, type=2, From 6b6f96a96b25a9e72584155562786433c0b8c66e Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 20 Oct 2024 18:58:51 -0400 Subject: [PATCH 28/28] run ruff format, lines too long, and autofixes --- src/AutoSplit.py | 144 ++++++++++----- src/AutoSplitImage.py | 7 +- src/capture_method/BitBltCaptureMethod.py | 5 +- src/capture_method/CaptureMethodBase.py | 6 +- .../DesktopDuplicationCaptureMethod.py | 6 +- .../Screenshot using QT attempt.py | 7 +- .../VideoCaptureDeviceCaptureMethod.py | 3 +- .../WindowsGraphicsCaptureMethod.py | 30 ++-- src/capture_method/XcbCaptureMethod.py | 9 +- src/capture_method/__init__.py | 36 ++-- src/compare.py | 11 +- src/error_messages.py | 78 +++++--- src/hotkeys.py | 68 ++++--- src/menu_bar.py | 169 +++++++++++++----- src/region_selection.py | 22 ++- src/split_parser.py | 25 +-- src/user_profile.py | 22 ++- src/utils.py | 23 ++- 18 files changed, 457 insertions(+), 214 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 7302d02c..cdaf31c3 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -22,7 +22,13 @@ from AutoSplitImage import AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker -from hotkeys import HOTKEYS, KEYBOARD_GROUPS_ISSUE, KEYBOARD_UINPUT_ISSUE, after_setting_hotkey, send_command +from hotkeys import ( + HOTKEYS, + KEYBOARD_GROUPS_ISSUE, + KEYBOARD_UINPUT_ISSUE, + after_setting_hotkey, + send_command, +) from menu_bar import ( about_qt, about_qt_for_python, @@ -34,7 +40,13 @@ view_help, ) from region_selection import align_region, select_region, select_window -from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images, validate_before_parsing +from split_parser import ( + BELOW_FLAG, + DUMMY_FLAG, + PAUSE_FLAG, + parse_and_validate_images, + validate_before_parsing, +) from user_profile import DEFAULT_PROFILE from utils import ( AUTOSPLIT_VERSION, @@ -52,6 +64,7 @@ if sys.platform == "win32": from win32comext.shell import shell as shell32 + myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" shell32.SetCurrentProcessExplicitAppUserModelID(myappid) @@ -120,8 +133,8 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.setupUi(self) self.setWindowTitle( - f"AutoSplit v{AUTOSPLIT_VERSION}" + - (" (externally controlled)" if self.is_auto_controlled else ""), + f"AutoSplit v{AUTOSPLIT_VERSION}" + + (" (externally controlled)" if self.is_auto_controlled else ""), ) # Hotkeys need to be initialized to be passed as thread arguments in hotkeys.py @@ -167,12 +180,18 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.skip_split_button.clicked.connect(self.skip_split) self.undo_split_button.clicked.connect(self.undo_split) self.next_image_button.clicked.connect(lambda: self.skip_split(navigate_image_only=True)) - self.previous_image_button.clicked.connect(lambda: self.undo_split(navigate_image_only=True)) + self.previous_image_button.clicked.connect( + lambda: self.undo_split(navigate_image_only=True) + ) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_images_button.clicked.connect(lambda: self.__reload_images(started_by_button=True)) + self.reload_images_button.clicked.connect( + lambda: self.__reload_images(started_by_button=True) + ) self.action_check_for_updates_on_open.changed.connect( - lambda: user_profile.set_check_for_updates_on_open(self, self.action_check_for_updates_on_open.isChecked()), + lambda: user_profile.set_check_for_updates_on_open( + self, self.action_check_for_updates_on_open.isChecked() + ), ) # update x, y, width, and height when changing the value of these spinbox's are changed @@ -244,10 +263,12 @@ def update_live_image_details(self, capture: MatLike | None): def __reload_images(self, *, started_by_button: bool = False): """ - Not thread safe (if triggered by LiveSplit for example). Use `reload_images_signal.emit` instead. + Not thread safe (if triggered by LiveSplit for example). + Use `reload_images_signal.emit` instead. 1. Stops the automated comparison checks and clear the current Split Image. - 2. Re-initializes all values affected by the automated checks. Assume we may get completely different images. + 2. Re-initializes all values affected by the automated checks. + Assume we may get completely different images. Or even no image where there was one before. 3. If validation passed: - - Updates the shown Split Image, Start Image text and Reset Image text. @@ -260,7 +281,8 @@ def __reload_images(self, *, started_by_button: bool = False): self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_reset) - # Reset values that can be edited by __compare_capture_for_auto_start or __compare_capture_for_auto_reset + # Reset values that can be edited by + # __compare_capture_for_auto_start or __compare_capture_for_auto_reset # Start related self.split_image_number = 0 self.highest_similarity = 0.0 @@ -281,14 +303,18 @@ def __reload_images(self, *, started_by_button: bool = False): parse_and_validate_images(self) if self.start_image: - self.table_current_image_threshold_label.setText(decimal(self.start_image.get_similarity_threshold(self))) + self.table_current_image_threshold_label.setText( + decimal(self.start_image.get_similarity_threshold(self)) + ) self.start_image_status_value_label.setText("ready") self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) if self.reset_image: self.table_reset_image_live_label.setText("-") self.table_reset_image_highest_label.setText("-") - self.table_reset_image_threshold_label.setText(decimal(self.reset_image.get_similarity_threshold(self))) + self.table_reset_image_threshold_label.setText( + decimal(self.reset_image.get_similarity_threshold(self)) + ) self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_reset) QApplication.processEvents() @@ -306,14 +332,14 @@ def __compare_capture_for_auto_start(self, capture: MatLike | None): start_image_similarity = self.start_image.compare_with_capture(self, capture) # If the similarity becomes higher than highest similarity, set it as such. - if start_image_similarity > self.highest_similarity: - self.highest_similarity = start_image_similarity + self.highest_similarity = max(start_image_similarity, self.highest_similarity) self.table_current_image_live_label.setText(decimal(start_image_similarity)) self.table_current_image_highest_label.setText(decimal(self.highest_similarity)) self.table_current_image_threshold_label.setText(decimal(start_image_threshold)) - # If the {b} flag is set, let similarity go above threshold first, then split on similarity below threshold + # If the {b} flag is set, let similarity go above threshold first, + # then split on similarity below threshold # Otherwise just split when similarity goes above threshold # TODO: Abstract with similar check in split image below_flag = self.start_image.check_flag(BELOW_FLAG) @@ -324,10 +350,12 @@ def __compare_capture_for_auto_start(self, capture: MatLike | None): self.split_below_threshold = True return - if ( - (below_flag and self.split_below_threshold and similarity_diff < 0 and is_valid_image(capture)) # noqa: PLR0916 # See above TODO - or (not below_flag and similarity_diff >= 0) - ): + if ( # noqa: PLR0916 # See above TODO + below_flag + and self.split_below_threshold + and similarity_diff < 0 + and is_valid_image(capture) + ) or (not below_flag and similarity_diff >= 0): self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.split_below_threshold = False @@ -369,7 +397,10 @@ def __take_screenshot(self): error_messages.region() return - screenshot_directory = self.settings_dict["screenshot_directory"] or self.settings_dict["split_image_directory"] + screenshot_directory = ( + self.settings_dict["screenshot_directory"] + or self.settings_dict["split_image_directory"] + ) if not screenshot_directory: error_messages.split_image_directory() return @@ -382,7 +413,9 @@ def __take_screenshot(self): # which is a problem, but I doubt anyone will get to 1000 split images... screenshot_index = 1 while True: - screenshot_path = os.path.join(screenshot_directory, f"{screenshot_index:03}_SplitImage.png") + screenshot_path = os.path.join( + screenshot_directory, f"{screenshot_index:03}_SplitImage.png" + ) if not os.path.exists(screenshot_path): break screenshot_index += 1 @@ -468,7 +501,11 @@ def skip_split(self, *, navigate_image_only: bool = False): if ( not self.is_running or "Delayed Split" in self.current_split_image.text() - or not (self.skip_split_button.isEnabled() or self.is_auto_controlled or navigate_image_only) + or not ( + self.skip_split_button.isEnabled() # fmt: skip + or self.is_auto_controlled + or navigate_image_only + ) or self.__is_current_split_out_of_range() ): return @@ -498,11 +535,13 @@ def reset(self): self.reset_highest_similarity = 0.0 self.is_running = False - # Functions for the hotkeys to return to the main thread from signals and start their corresponding functions + # Functions for the hotkeys to return to the main thread from signals + # and start their corresponding functions def start_auto_splitter(self): - # If the auto splitter is already running or the button is disabled, don't emit the signal to start it. + # If the auto splitter is already running or the button is disabled, + # don't emit the signal to start it. if ( - self.is_running + self.is_running # fmt: skip or (not self.start_auto_splitter_button.isEnabled() and not self.is_auto_controlled) ): return @@ -513,7 +552,7 @@ def start_auto_splitter(self): self.start_auto_splitter_signal.emit() - def __auto_splitter(self): # noqa: PLR0912,PLR0915 + def __auto_splitter(self): # noqa: C901,PLR0912,PLR0915 if not self.settings_dict["split_hotkey"] and not self.is_auto_controlled: self.gui_changes_on_reset(safe_to_reload_start_image=True) error_messages.split_hotkey() @@ -523,7 +562,8 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 self.run_start_time = time() if not (validate_before_parsing(self) and parse_and_validate_images(self)): - # `safe_to_reload_start_image: bool = False` because __reload_start_and_reset_images also does this check, + # `safe_to_reload_start_image: bool = False` + # because __reload_start_and_reset_images also does this check, # we don't want to double a Start/Reset Image error message self.gui_changes_on_reset() return @@ -532,8 +572,7 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 self.split_images_and_loop_number = list( flatten( ((split_image, i + 1) for i in range(split_image.loops)) - for split_image - in self.split_images + for split_image in self.split_images ), ) @@ -601,7 +640,8 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 button.setEnabled(False) self.current_image_file_label.clear() - # check for reset while delayed and display a counter of the remaining split delay time + # check for reset while delayed and + # display a counter of the remaining split delay time if self.__pause_loop(split_delay, "Delayed Split:"): return @@ -615,14 +655,18 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 # if loop check box is checked and its the last split, go to first split. # else go to the next split image. - if self.settings_dict["loop_splits"] and self.split_image_number == number_of_split_images - 1: + if ( + self.settings_dict["loop_splits"] + and self.split_image_number == number_of_split_images - 1 + ): self.split_image_number = 0 else: self.split_image_number += 1 # If its not the last split image, pause for the amount set by the user # A pause loop to check if the user presses skip split, undo split, or reset here. - # Also updates the current split image text, counting down the time until the next split image + # Also updates the current split image text, + # counting down the time until the next split image if self.__pause_loop(self.split_image.get_pause_time(self), "None (Paused)."): return @@ -630,7 +674,9 @@ def __auto_splitter(self): # noqa: PLR0912,PLR0915 self.reset() self.gui_changes_on_reset(safe_to_reload_start_image=True) - def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_array: list[bool]): + def __similarity_threshold_loop( + self, number_of_split_images: int, dummy_splits_array: list[bool] + ): """ Wait until the similarity threshold is met. @@ -653,8 +699,7 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ self.table_current_image_live_label.setText(decimal(similarity)) # if the similarity becomes higher than highest similarity, set it as such. - if similarity > self.highest_similarity: - self.highest_similarity = similarity + self.highest_similarity = max(similarity, self.highest_similarity) # show live highest similarity if the checkbox is checked self.table_current_image_highest_label.setText(decimal(self.highest_similarity)) @@ -664,8 +709,11 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ self.next_image_button.setEnabled(self.split_image_number != number_of_split_images - 1) self.previous_image_button.setEnabled(self.split_image_number != 0) if not self.is_auto_controlled: - # If its the last non-dummy split image and last loop number, disable the skip split button - self.skip_split_button.setEnabled(dummy_splits_array[self.split_image_number :].count(False) > 1) + # If its the last non-dummy split image and last loop number, + # disable the skip split button + self.skip_split_button.setEnabled( + dummy_splits_array[self.split_image_number :].count(False) > 1 + ) self.undo_split_button.setEnabled(self.split_image_number != 0) QApplication.processEvents() @@ -725,7 +773,9 @@ def __pause_loop(self, stop_time: float, message: str): ): break - self.current_split_image.setText(f"{message} {seconds_remaining_text(stop_time - time_delta)}") + self.current_split_image.setText( + f"{message} {seconds_remaining_text(stop_time - time_delta)}" + ) QTest.qWait(1) return False @@ -806,7 +856,10 @@ def __compare_capture_for_auto_reset(self, capture: MatLike | None): self.reset() def __reset_gui_if_not_running(self): - """Checks if we are in a "not running" state, update GUI if it's the case, and returns the result.""" + """ + Checks if we are in a "not running" state, update GUI if it's the case, + and returns the result. + """ if not self.is_running: self.gui_changes_on_reset(safe_to_reload_start_image=True) return True @@ -824,12 +877,17 @@ def __update_split_image(self, specific_image: AutoSplitImage | None = None): return # Get split image - self.split_image = specific_image or self.split_images_and_loop_number[0 + self.split_image_number][0] + self.split_image = ( + specific_image # fmt: skip + or self.split_images_and_loop_number[0 + self.split_image_number][0] + ) if is_valid_image(self.split_image.byte_array): set_preview_image(self.current_split_image, self.split_image.byte_array) self.current_image_file_label.setText(self.split_image.filename) - self.table_current_image_threshold_label.setText(decimal(self.split_image.get_similarity_threshold(self))) + self.table_current_image_threshold_label.setText( + decimal(self.split_image.get_similarity_threshold(self)) + ) # Set Image Loop number if specific_image and specific_image.image_type == ImageType.START: @@ -872,7 +930,9 @@ def exit_program() -> NoReturn: self, "AutoSplit", f"Do you want to save changes made to settings file {settings_file_name}?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, ) if warning is QMessageBox.StandardButton.Yes: diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py index d4176560..3a03ff8c 100644 --- a/src/AutoSplitImage.py +++ b/src/AutoSplitImage.py @@ -110,9 +110,12 @@ def __read_image_bytes(self, path: str): if self._has_transparency: # Adaptively determine the target size according to # the number of nonzero elements in the alpha channel of the split image. - # This may result in images bigger than COMPARISON_RESIZE if there's plenty of transparency. + # This may result in images bigger than COMPARISON_RESIZE if there's plenty of transparency. # noqa: E501 # Which wouldn't incur any performance loss in methods where masked regions are ignored. - scale = min(1, sqrt(COMPARISON_RESIZE_AREA / cv2.countNonZero(image[:, :, ColorChannel.Alpha]))) + scale = min( + 1, + sqrt(COMPARISON_RESIZE_AREA / cv2.countNonZero(image[:, :, ColorChannel.Alpha])), + ) image = cv2.resize( image, diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 8f0b495e..e8d36898 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -40,7 +40,10 @@ class BitBltCaptureMethod(ThreadedLoopCaptureMethod): @override def window_recovery_message(self): if type(self) is BitBltCaptureMethod: - return super().window_recovery_message + "\n(captured window may be incompatible with BitBlt)" + return ( + super().window_recovery_message + + "\n(captured window may be incompatible with BitBlt)" + ) return super().window_recovery_message _render_full_content = False diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index 4926da12..f69bacb1 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -90,7 +90,11 @@ def _read_action(self) -> MatLike | None: @final def __read_loop(self): # Very useful debug print - # print("subscriptions:", len(self._subscriptions), [x.__name__ for x in self._subscriptions]) + # print( + # "subscriptions:", + # len(self._subscriptions), + # [x.__name__ for x in self._subscriptions], + # ) if len(self._subscriptions) == 0: # optimisation on idle: no subscriber means no work needed return diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 6051d1ee..5ab765b6 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -47,9 +47,9 @@ def _read_action(self): left_bounds, top_bounds, *_ = get_window_bounds(hwnd) self.desktop_duplication.display = next( - display for display - in self.desktop_duplication.displays - if display.hmonitor == hmonitor + display + for display in self.desktop_duplication.displays + if display.hmonitor == hmonitor # fmt: skip ) offset_x, offset_y, *_ = win32gui.GetWindowRect(hwnd) offset_x -= self.desktop_duplication.display.position["left"] diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py index 7846abd1..f489604d 100644 --- a/src/capture_method/Screenshot using QT attempt.py +++ b/src/capture_method/Screenshot using QT attempt.py @@ -1,17 +1,18 @@ -# flake8: noqa +# ruff: noqa: RET504 import sys if sys.platform != "linux": - raise OSError() + raise OSError from typing import cast import numpy as np from cv2.typing import MatLike from PySide6.QtCore import QBuffer, QIODeviceBase from PySide6.QtGui import QGuiApplication -from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from typing_extensions import override +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod + class QtCaptureMethod(ThreadedLoopCaptureMethod): _render_full_content = False diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 7f778e41..7cf578e5 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -79,7 +79,8 @@ def _read_action(self): if not ( cv2_error.code == cv2.Error.STS_ERROR and ( - # Likely means the camera is occupied OR the camera index is out of range (like -1) + # Likely means the camera is occupied + # OR the camera index is out of range (like -1) cv2_error.msg.endswith("in function 'cv::VideoCapture::grab'\n") # Some capture cards we cannot use directly # https://github.com/opencv/opencv/issues/23539 diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 5e0fb480..e01f6674 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -17,14 +17,18 @@ from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod -from utils import BGRA_CHANNEL_COUNT, WGC_MIN_BUILD, WINDOWS_BUILD_NUMBER, get_direct3d_device, is_valid_hwnd +from utils import ( + BGRA_CHANNEL_COUNT, + WGC_MIN_BUILD, + WINDOWS_BUILD_NUMBER, + get_direct3d_device, + is_valid_hwnd, +) if TYPE_CHECKING: from AutoSplit import AutoSplit WGC_NO_BORDER_MIN_BUILD = 20348 -LEARNING_MODE_DEVICE_BUILD = 17763 -"""https://learn.microsoft.com/en-us/uwp/api/windows.ai.machinelearning.learningmodeldevice""" WGC_QTIMER_LIMIT = 30 @@ -38,8 +42,6 @@ class WindowsGraphicsCaptureMethod(ThreadedLoopCaptureMethod): short_description = "fast, most compatible, capped at 60fps" description = ( f"\nOnly available in Windows 10.0.{WGC_MIN_BUILD} and up. " - + f"\nDue to current technical limitations, Windows versions below 10.0.0.{LEARNING_MODE_DEVICE_BUILD}" - + "\nrequire having at least one audio or video Capture Device connected and enabled." + "\nAllows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows. " + "\nAdds a yellow border on Windows 10 (not on Windows 11)." + "\nCaps at around 60 FPS. " @@ -86,8 +88,8 @@ def close(self): try: self.session.close() except OSError: - # OSError: The application called an interface that was marshalled for a different thread - # This still seems to close the session and prevent the following hard crash in LiveSplit + # OSError: The application called an interface that was marshalled for a different thread # noqa: E501 + # This still seems to close the session and prevent the following hard crash in LiveSplit # noqa: E501 # "AutoSplit.exe " # noqa: E501 pass self.session = None @@ -95,8 +97,8 @@ def close(self): @override def set_fps_limit(self, fps: int): """ - There's an issue in the interaction between QTimer and WGC API where setting the interval to even 1 ms - causes twice as many "called `try_get_next_frame` to fast. + There's an issue in the interaction between QTimer and WGC API where setting the interval to + even 1 ms causes twice as many "called `try_get_next_frame` too fast". So for FPS target above 30, we unlock interval speed. """ super().set_fps_limit(fps if fps <= WGC_QTIMER_LIMIT else 0) @@ -138,8 +140,8 @@ def _read_action(self) -> MatLike | None: image = np.frombuffer(cast(bytes, reference), dtype=np.uint8) image.shape = (self.size.height, self.size.width, BGRA_CHANNEL_COUNT) return image[ - selection["y"]: selection["y"] + selection["height"], - selection["x"]: selection["x"] + selection["width"], + selection["y"] : selection["y"] + selection["height"], + selection["x"] : selection["x"] + selection["width"], ] @override @@ -148,8 +150,8 @@ def recover_window(self, captured_window_title: str): if not is_valid_hwnd(hwnd): return False - # Because of async image obtention and capture initialization, - # AutoSplit could ask for an image too soon after having called recover_window() last iteration. + # Because of async image obtention and capture initialization, AutoSplit + # could ask for an image too soon after having called recover_window() last iteration. # WGC *would* have returned an image, but it's asked to reinitialize over again. if self._autosplit_ref.hwnd == hwnd and self.check_selected_region_exists(): return True @@ -167,7 +169,7 @@ def recover_window(self, captured_window_title: str): @override def check_selected_region_exists(self): return bool( - is_valid_hwnd(self._autosplit_ref.hwnd) + is_valid_hwnd(self._autosplit_ref.hwnd) # fmt: skip and self.frame_pool and self.session, ) diff --git a/src/capture_method/XcbCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py index bcb6c095..8e400432 100644 --- a/src/capture_method/XcbCaptureMethod.py +++ b/src/capture_method/XcbCaptureMethod.py @@ -32,7 +32,14 @@ def _read_action(self): return None offset_x = data["x"] offset_y = data["y"] - # image = window.get_image(selection["x"], selection["y"], selection["width"], selection["height"], 1, 0) + # image = window.get_image( + # selection["x"], + # selection["y"], + # selection["width"], + # selection["height"], + # 1, + # 0, + # ) selection = self._autosplit_ref.settings_dict["capture_region"] x = selection["x"] + offset_x diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 3c5c28b1..e08e8994 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -1,13 +1,12 @@ -import asyncio import os import sys from collections import OrderedDict from dataclasses import dataclass from enum import Enum, EnumMeta, auto, unique from itertools import starmap -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, Never, TypedDict, cast -from typing_extensions import Never, override +from typing_extensions import override from capture_method.CaptureMethodBase import CaptureMethodBase from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod @@ -15,11 +14,14 @@ if sys.platform == "win32": from _ctypes import COMError # noqa: PLC2701 + from pygrabber.dshow_graph import FilterGraph from capture_method.BitBltCaptureMethod import BitBltCaptureMethod from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod - from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod + from capture_method.ForceFullContentRenderingCaptureMethod import ( + ForceFullContentRenderingCaptureMethod, + ) from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod if sys.platform == "linux": @@ -44,9 +46,9 @@ class Region(TypedDict): class CaptureMethodEnumMeta(EnumMeta): # Allow checking if simple string is enum @override - def __contains__(self, other: object): + def __contains__(cls, other: object): try: - self(other) + cls(other) except ValueError: return False return True @@ -112,15 +114,15 @@ def get_method_by_index(self, index: int): # Disallow unsafe get w/o breaking it at runtime @override def __getitem__( # type:ignore[override] # pyright: ignore[reportIncompatibleMethodOverride] - self, - __key: Never, + self, key: Never, / ) -> type[CaptureMethodBase]: - return super().__getitem__(__key) + return super().__getitem__(key) @override def get(self, key: CaptureMethodEnum, default: object = None, /): """ - Returns the `CaptureMethodBase` subclass for `CaptureMethodEnum` if `CaptureMethodEnum` is available, + Returns the `CaptureMethodBase` subclass for `CaptureMethodEnum` + if `CaptureMethodEnum` is available, else defaults to the first available `CaptureMethodEnum`. Returns `CaptureMethodBase` directly if there's no capture methods. """ @@ -133,7 +135,8 @@ def get(self, key: CaptureMethodEnum, default: object = None, /): if sys.platform == "win32": if ( # Windows Graphics Capture requires a minimum Windows Build WINDOWS_BUILD_NUMBER >= WGC_MIN_BUILD - # Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice + # Our current implementation of Windows Graphics Capture + # does not ensure we can get an ID3DDevice and try_get_direct3d_device() ): CAPTURE_METHODS[CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE] = WindowsGraphicsCaptureMethod @@ -146,7 +149,9 @@ def get(self, key: CaptureMethodEnum, default: object = None, /): pass else: CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = DesktopDuplicationCaptureMethod - CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ForceFullContentRenderingCaptureMethod + CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = ( + ForceFullContentRenderingCaptureMethod + ) elif sys.platform == "linux": if features.check_feature(feature="xcb"): CAPTURE_METHODS[CaptureMethodEnum.XCB] = XcbCaptureMethod @@ -215,10 +220,10 @@ def get_input_device_resolution(index: int) -> tuple[int, int] | None: return resolution -async def get_all_video_capture_devices(): +def get_all_video_capture_devices(): named_video_inputs = get_input_devices() - async def get_camera_info(index: int, device_name: str): + def get_camera_info(index: int, device_name: str): backend = "" # Probing freezes some devices (like GV-USB2 and AverMedia) if already in use. See #169 # FIXME: Maybe offer the option to the user to obtain more info about their devices? @@ -247,7 +252,6 @@ async def get_camera_info(index: int, device_name: str): return [ camera_info - for camera_info - in await asyncio.gather(*starmap(get_camera_info, enumerate(named_video_inputs))) + for camera_info in starmap(get_camera_info, enumerate(named_video_inputs)) if camera_info is not None ] diff --git a/src/compare.py b/src/compare.py index f93788da..a9f4bd79 100644 --- a/src/compare.py +++ b/src/compare.py @@ -72,7 +72,7 @@ def compare_template(source: MatLike, capture: MatLike, mask: MatLike | None = N # matchTemplate returns the sum of square differences, this is the max # that the value can be. Used for normalizing from 0 to 1. max_error = ( - source.size * MAXBYTE * MAXBYTE + source.size * MAXBYTE * MAXBYTE # fmt: skip if not is_valid_image(mask) else cv2.countNonZero(mask) ) @@ -82,7 +82,8 @@ def compare_template(source: MatLike, capture: MatLike, mask: MatLike | None = N def __cv2_phash(image: MatLike, hash_size: int = 8, highfreq_factor: int = 4): """Implementation copied from https://github.com/JohannesBuchner/imagehash/blob/38005924fe9be17cfed145bbc6d83b09ef8be025/imagehash/__init__.py#L260 .""" # noqa: E501 - # OpenCV has its own pHash comparison implementation in `cv2.img_hash`, but it requires contrib/extra modules + # OpenCV has its own pHash comparison implementation in `cv2.img_hash`, + # but it requires contrib/extra modules # and is innacurate unless we precompute the size with a specific interpolation. # See: https://github.com/opencv/opencv_contrib/issues/3295#issuecomment-1172878684 # @@ -143,7 +144,8 @@ def get_comparison_method_by_index(comparison_method_index: int): def check_if_image_has_transparency(image: MatLike): - # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) + # Check if there's a transparency channel (4th channel) + # and if at least one pixel is transparent (< 255) if image.shape[ImageShape.Channels] != BGRA_CHANNEL_COUNT: return False mean: float = image[:, :, ColorChannel.Alpha].mean() @@ -151,6 +153,7 @@ def check_if_image_has_transparency(image: MatLike): # Non-transparent images code path is usually faster and simpler, so let's return that return False # TODO: error message if all pixels are transparent - # (the image appears as all black in windows, so it's not obvious for the user what they did wrong) + # (the image appears as all black in windows, + # so it's not obvious for the user what they did wrong) return mean != MAXBYTE diff --git a/src/error_messages.py b/src/error_messages.py index c07aecd9..3eefbabc 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -15,13 +15,18 @@ from AutoSplit import AutoSplit -def __exit_program(): +def __exit_program() -> NoReturn: # stop main thread (which is probably blocked reading input) via an interrupt signal os.kill(os.getpid(), signal.SIGINT) sys.exit(1) -def set_text_message(message: str, details: str = "", kill_button: str = "", accept_button: str = ""): +def set_text_message( + message: str, + details: str = "", + kill_button: str = "", + accept_button: str = "", +): message_box = QtWidgets.QMessageBox() message_box.setWindowTitle("Error") message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) @@ -30,7 +35,9 @@ def set_text_message(message: str, details: str = "", kill_button: str = "", acc if accept_button: message_box.addButton(accept_button, QtWidgets.QMessageBox.ButtonRole.AcceptRole) if kill_button: - force_quit_button = message_box.addButton(kill_button, QtWidgets.QMessageBox.ButtonRole.ResetRole) + force_quit_button = message_box.addButton( + kill_button, QtWidgets.QMessageBox.ButtonRole.ResetRole + ) force_quit_button.clicked.connect(__exit_program) if details: message_box.setDetailedText(details) @@ -74,7 +81,8 @@ def split_hotkey(): def pause_hotkey(): set_text_message( - "Your split image folder contains an image filename with a pause flag {p}, but no pause hotkey is set.", + "Your split image folder contains an image filename with a pause flag {p}, " + + "but no pause hotkey is set." ) @@ -96,7 +104,9 @@ def reset_hotkey(): def old_version_settings_file(): set_text_message( - "Old version settings file detected. This version allows settings files in .toml format. Starting from v2.0.", + "Old version settings file detected. " + + "This version allows settings files in .toml format. " + + "Starting from v2.0." ) @@ -110,19 +120,22 @@ def invalid_hotkey(hotkey_name: str): def no_settings_file_on_open(): set_text_message( - "No settings file found. One can be loaded on open if placed in the same folder as the AutoSplit executable.", + "No settings file found. " + + "One can be loaded on open if placed in the same folder as the AutoSplit executable." ) def too_many_settings_files_on_open(): set_text_message( "Too many settings files found. " - + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable.", + + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable." ) def check_for_updates(): - set_text_message("An error occurred while attempting to check for updates. Please check your connection.") + set_text_message( + "An error occurred while attempting to check for updates. Please check your connection." + ) def load_start_image(): @@ -133,12 +146,15 @@ def load_start_image(): def stdin_lost(): - set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") + set_text_message( + "stdin not supported or lost, external control like LiveSplit integration will not work." + ) def already_open(): set_text_message( - "An instance of AutoSplit is already running.
Are you sure you want to open a another one?", + "An instance of AutoSplit is already running." + + "
Are you sure you want to open a another one?", "", "Don't open", "Ignore", @@ -150,14 +166,15 @@ def linux_groups(): "Linux users must ensure they are in the 'tty' and 'input' groups " + "and have write access to '/dev/uinput'. You can run the following commands to do so:", # Keep in sync with README.md and scripts/install.ps1 - "sudo usermod -a -G tty,input $USER" - + "\nsudo touch /dev/uinput" - + "\nsudo chmod +0666 /dev/uinput" - + "\necho 'KERNEL==\"uinput\", TAG+=\"uaccess\"' | sudo tee /etc/udev/rules.d/50-uinput.rules" - + "\necho 'SUBSYSTEM==\"input\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee /etc/udev/rules.d/12-input.rules" - + "\necho 'SUBSYSTEM==\"misc\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" - + "\necho 'SUBSYSTEM==\"tty\", MODE=\"0666\" GROUP=\"plugdev\"' | sudo tee -a /etc/udev/rules.d/12-input.rules" - + "\nloginctl terminate-user $USER", + """\ +sudo usermod -a -G tty,input $USER +sudo touch /dev/uinput +sudo chmod +0666 /dev/uinput +echo 'KERNEL=="uinput", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/50-uinput.rules +echo 'SUBSYSTEM=="input", MODE="0666" GROUP="plugdev"' | sudo tee /etc/udev/rules.d/12-input.rules +echo 'SUBSYSTEM=="misc", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules +echo 'SUBSYSTEM=="tty", MODE="0666" GROUP="plugdev"' | sudo tee -a /etc/udev/rules.d/12-input.rules +loginctl terminate-user $USER""", ) @@ -170,9 +187,11 @@ def linux_uinput(): # Keep in sync with README.md#DOWNLOAD_AND_OPEN -WAYLAND_WARNING = "All screen capture method are incompatible with Wayland. Follow this guide to disable it: " \ - + '\n' \ +WAYLAND_WARNING = ( + "All screen capture method are incompatible with Wayland. Follow this guide to disable it: " + + '\n' + "https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop" +) def linux_wayland(): @@ -183,7 +202,8 @@ def exception_traceback(exception: BaseException, message: str = ""): if not message: message = ( "AutoSplit encountered an unhandled exception and will try to recover, " - + f"however, there is no guarantee it will keep working properly. {CREATE_NEW_ISSUE_MESSAGE}" + + "however, there is no guarantee it will keep working properly. " + + CREATE_NEW_ISSUE_MESSAGE ) set_text_message( message, @@ -200,14 +220,19 @@ def exception_traceback(exception: BaseException, message: str = ""): def make_excepthook(autosplit: "AutoSplit"): - def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: TracebackType | None): + def excepthook( + exception_type: type[BaseException], + exception: BaseException, + _traceback: TracebackType | None, + ): # Catch Keyboard Interrupts for a clean close if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): sys.exit(0) - # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture + # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture # noqa: E501 if ( exception_type is SystemError - and str(exception) == " returned a result with an error set" + and str(exception) + == " returned a result with an error set" ): return # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors @@ -217,7 +242,10 @@ def excepthook(exception_type: type[BaseException], exception: BaseException, _t def handle_top_level_exceptions(exception: Exception) -> NoReturn: - message = f"AutoSplit encountered an unrecoverable exception and will likely now close. {CREATE_NEW_ISSUE_MESSAGE}" + message = ( + "AutoSplit encountered an unrecoverable exception and will likely now close. " + + CREATE_NEW_ISSUE_MESSAGE + ) # Print error to console if not running in executable if FROZEN: exception_traceback(exception, message) diff --git a/src/hotkeys.py b/src/hotkeys.py index d1664195..a6438f37 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -30,8 +30,24 @@ PRESS_A_KEY_TEXT = "Press a key..." Commands = Literal["split", "start", "pause", "reset", "skip", "undo"] -Hotkey = Literal["split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image"] -HOTKEYS = ("split", "reset", "skip_split", "undo_split", "pause", "screenshot", "toggle_auto_reset_image") +Hotkey = Literal[ + "split", + "reset", + "skip_split", + "undo_split", + "pause", + "screenshot", + "toggle_auto_reset_image", +] +HOTKEYS = ( + "split", + "reset", + "skip_split", + "undo_split", + "pause", + "screenshot", + "toggle_auto_reset_image", +) def remove_all_hotkeys(): @@ -56,7 +72,10 @@ def after_setting_hotkey(autosplit: "AutoSplit"): autosplit.start_auto_splitter_button.setEnabled(True) if autosplit.SettingsWidget: for hotkey in HOTKEYS: - getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(SET_HOTKEY_TEXT) + getattr( + autosplit.SettingsWidget, + f"set_{hotkey}_hotkey_button", + ).setText(SET_HOTKEY_TEXT) getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) @@ -93,7 +112,7 @@ def _send_hotkey(hotkey_or_scan_code: int | str | None): # Deal with regular inputs # If an int or does not contain the following strings - if ( + if ( # fmt: skip isinstance(hotkey_or_scan_code, int) or not any(key in hotkey_or_scan_code for key in ("num ", "decimal", "+")) ): @@ -101,16 +120,14 @@ def _send_hotkey(hotkey_or_scan_code: int | str | None): return # FIXME: Localized keys won't work here - # Deal with problematic keys. Even by sending specific scan code "keyboard" still sends the default (wrong) key + # Deal with problematic keys. + # Even by sending specific scan code "keyboard" still sends the default (wrong) key # keyboard also has issues with capitalization modifier (shift+A) # keyboard.send(keyboard.key_to_scan_codes(key_or_scan_code)[1]) - pyautogui.hotkey( - *[ - "+" if key == "plus" else key - for key - in hotkey_or_scan_code.replace(" ", "").split("+") - ], - ) + pyautogui.hotkey(*[ + "+" if key == "plus" else key # fmt: skip + for key in hotkey_or_scan_code.replace(" ", "").split("+") + ]) def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) -> bool: @@ -120,13 +137,15 @@ def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) For example, "Home", "Num Home" and "Num 7" are all `71`. See: https://github.com/boppreh/keyboard/issues/171#issuecomment-390437684 . - Since we reuse the key string we set to send to LiveSplit, we can't use fake names like "num home". + Since we reuse the key string we set to send to LiveSplit, + we can't use fake names like "num home". We're also trying to achieve the same hotkey behaviour as LiveSplit has. """ # Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other # as well as "." and "(keypad)./decimal" if keyboard_event.scan_code in {83, 52}: - # TODO: "del" won't work with "(keypad)delete" if localized in non-english (ie: "suppr" in french) + # TODO: "del" won't work with "(keypad)delete" if localized in non-english + # (ie: "suppr" in french) return expected_key == keyboard_event.name # Prevent "action keys" from triggering "keypad keys" if keyboard_event.name and is_digit(keyboard_event.name[-1]): @@ -142,13 +161,17 @@ def __validate_keypad(expected_key: str, keyboard_event: keyboard.KeyboardEvent) return not is_digit(expected_key[-1]) -def _hotkey_action(keyboard_event: keyboard.KeyboardEvent, key_name: str, action: Callable[[], None]): +def _hotkey_action( + keyboard_event: keyboard.KeyboardEvent, key_name: str, action: Callable[[], None] +): """ We're doing the check here instead of saving the key code because the non-keypad shared keys are localized while the keypad ones aren't. They also share scan codes on Windows. """ - if keyboard_event.event_type == keyboard.KEY_DOWN and __validate_keypad(key_name, keyboard_event): + if keyboard_event.event_type == keyboard.KEY_DOWN and __validate_keypad( + key_name, keyboard_event + ): action() @@ -243,10 +266,10 @@ def toggle_auto_reset_image(): def is_valid_hotkey_name(hotkey_name: str): return any( key and not keyboard.is_modifier(keyboard.key_to_scan_codes(key)[0]) - for key - in hotkey_name.split("+") + for key in hotkey_name.split("+") ) + # TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to # reduce duplicated code. We should use a dictionary of hotkey class or something. @@ -298,13 +321,16 @@ def read_and_set_hotkey(): setattr( autosplit, f"{hotkey}_hotkey", - # keyboard.add_hotkey doesn't give the last keyboard event, so we can't __validate_keypad. + # keyboard.add_hotkey doesn't give the last keyboard event, + # so we can't __validate_keypad. # This means "ctrl + num 5" and "ctrl + 5" will both be registered. # For that reason, we still prefer keyboard.hook_key for single keys. - # keyboard module allows you to hit multiple keys for a hotkey. they are joined together by +. + # keyboard module allows you to hit multiple keys for a hotkey. + # They are joined together by + . keyboard.add_hotkey(hotkey_name, action) if "+" in hotkey_name - # We need to inspect the event to know if it comes from numpad because of _canonial_names. + # We need to inspect the event to know if it comes from numpad + # because of _canonial_names. # See: https://github.com/boppreh/keyboard/issues/161#issuecomment-386825737 # The best way to achieve this is make our own hotkey handling on top of hook # See: https://github.com/boppreh/keyboard/issues/216#issuecomment-431999553 diff --git a/src/menu_bar.py b/src/menu_bar.py index 9352cd78..dd277d2b 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -1,4 +1,3 @@ -import asyncio import json import sys import webbrowser @@ -37,7 +36,7 @@ # Keep in sync with README.md#Capture_Method_Linux + '\n"scrot" must be installed to use SCReenshOT. ' + "\nRun: sudo apt-get install scrot" -) if sys.platform == "linux" else "" +) if sys.platform == "linux" else "" # fmt: skip class __AboutWidget(QtWidgets.QWidget, about.Ui_AboutAutoSplitWidget): # noqa: N801 # Private class @@ -58,7 +57,13 @@ def open_about(autosplit: "AutoSplit"): class __UpdateCheckerWidget(QtWidgets.QWidget, update_checker.Ui_UpdateChecker): # noqa: N801 # Private class - def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, *, check_on_open: bool = False): + def __init__( + self, + latest_version: str, + design_window: design.Ui_MainWindow, + *, + check_on_open: bool = False, + ): super().__init__() self.setupUi(self) self.current_version_number_label.setText(AUTOSPLIT_VERSION) @@ -90,8 +95,15 @@ def do_not_ask_me_again_state_changed(self): def open_update_checker(autosplit: "AutoSplit", latest_version: str, *, check_on_open: bool): - if not autosplit.UpdateCheckerWidget or cast(QtWidgets.QWidget, autosplit.UpdateCheckerWidget).isHidden(): - autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open=check_on_open) + if ( + not autosplit.UpdateCheckerWidget + or cast(QtWidgets.QWidget, autosplit.UpdateCheckerWidget).isHidden() + ): + autosplit.UpdateCheckerWidget = __UpdateCheckerWidget( + latest_version, + autosplit, + check_on_open=check_on_open, + ) def view_help(): @@ -107,10 +119,16 @@ def __init__(self, autosplit: "AutoSplit", *, check_on_open: bool): @override def run(self): try: - with urlopen(f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest", timeout=30) as response: # noqa: S310 + with urlopen( + f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest", + timeout=30, + ) as response: json_response: dict[str, str] = json.loads(response.read()) latest_version = json_response["name"].split("v")[1] - self._autosplit_ref.update_checker_widget_signal.emit(latest_version, self.check_on_open) + self._autosplit_ref.update_checker_widget_signal.emit( + latest_version, + self.check_on_open, + ) except (URLError, KeyError): if not self.check_on_open: self._autosplit_ref.show_error_signal.emit(error_messages.check_for_updates) @@ -125,7 +143,10 @@ def about_qt_for_python(): def check_for_updates(autosplit: "AutoSplit", *, check_on_open: bool = False): - autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open=check_on_open) + autosplit.CheckForUpdatesThread = __CheckForUpdatesThread( + autosplit, + check_on_open=check_on_open, + ) autosplit.CheckForUpdatesThread.start() @@ -135,7 +156,8 @@ def __init__(self, autosplit: "AutoSplit"): self.__video_capture_devices: list[CameraInfo] = [] """ Used to temporarily store the existing cameras, - we don't want to call `get_all_video_capture_devices` agains and possibly have a different result + we don't want to call `get_all_video_capture_devices` again + and possibly have a different result """ self.setupUi(self) @@ -156,11 +178,14 @@ def __init__(self, autosplit: "AutoSplit"): # Don't autofocus any particular field self.setFocus() -# region Build the Capture method combobox + # region Build the Capture method combobox + capture_method_values = CAPTURE_METHODS.values() self.__set_all_capture_devices() - # TODO: Word-wrapping works, but there's lots of extra padding to the right. Raise issue upstream + # TODO: Word-wrapping works, but there's lots of extra padding to the right. + # Raise issue upstream + # # list_view = QtWidgets.QListView() # list_view.setWordWrap(True) # list_view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -168,16 +193,15 @@ def __init__(self, autosplit: "AutoSplit"): # self.capture_method_combobox.setView(list_view) self.capture_method_combobox.addItems([ - f"- {method.name} ({method.short_description})" - for method in capture_method_values + f"- {method.name} ({method.short_description})" for method in capture_method_values ]) self.capture_method_combobox.setToolTip( "\n\n".join([ - f"{method.name} :\n{method.description}" - for method in capture_method_values - ]) + LINUX_SCREENSHOT_SUPPORT, + f"{method.name} :\n{method.description}" for method in capture_method_values + ]) + + LINUX_SCREENSHOT_SUPPORT ) -# endregion + # endregion self.__setup_bindings() @@ -202,7 +226,10 @@ def __set_value(self, key: str, value: Any): def get_capture_device_index(self, capture_device_id: int): """Returns 0 if the capture_device_id is invalid.""" try: - return [device.device_id for device in self.__video_capture_devices].index(capture_device_id) + return [ + device.device_id # fmt: skip + for device in self.__video_capture_devices + ].index(capture_device_id) except ValueError: return 0 @@ -216,14 +243,18 @@ def __enable_capture_device_if_its_selected_method( self.capture_device_combobox.setEnabled(is_video_capture_device) if is_video_capture_device: self.capture_device_combobox.setCurrentIndex( - self.get_capture_device_index(self._autosplit_ref.settings_dict["capture_device_id"]), + self.get_capture_device_index( + self._autosplit_ref.settings_dict["capture_device_id"] + ), ) else: self.capture_device_combobox.setPlaceholderText('Select "Video Capture Device" above') self.capture_device_combobox.setCurrentIndex(-1) def __capture_method_changed(self): - selected_capture_method = CAPTURE_METHODS.get_method_by_index(self.capture_method_combobox.currentIndex()) + selected_capture_method = CAPTURE_METHODS.get_method_by_index( + self.capture_method_combobox.currentIndex() + ) self.__enable_capture_device_if_its_selected_method(selected_capture_method) change_capture_method(selected_capture_method, self._autosplit_ref) return selected_capture_method @@ -235,7 +266,10 @@ def __capture_device_changed(self): capture_device = self.__video_capture_devices[device_index] self._autosplit_ref.settings_dict["capture_device_name"] = capture_device.name self._autosplit_ref.settings_dict["capture_device_id"] = capture_device.device_id - if self._autosplit_ref.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: + if ( + self._autosplit_ref.settings_dict["capture_method"] + == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE + ): # Re-initializes the VideoCaptureDeviceCaptureMethod change_capture_method(CaptureMethodEnum.VIDEO_CAPTURE_DEVICE, self._autosplit_ref) @@ -246,7 +280,7 @@ def __fps_limit_changed(self, value: int): @fire_and_forget def __set_all_capture_devices(self): - self.__video_capture_devices = asyncio.run(get_all_video_capture_devices()) + self.__video_capture_devices = get_all_video_capture_devices() if len(self.__video_capture_devices) > 0: for i in range(self.capture_device_combobox.count()): self.capture_device_combobox.removeItem(i) @@ -262,9 +296,7 @@ def __set_all_capture_devices(self): def __set_readme_link(self): self.custom_image_settings_info_label.setText( - self.custom_image_settings_info_label - .text() - .format(GITHUB_REPOSITORY=GITHUB_REPOSITORY), + self.custom_image_settings_info_label.text().format(GITHUB_REPOSITORY=GITHUB_REPOSITORY) ) # HACK: This is a workaround because custom_image_settings_info_label # simply will not open links with a left click no matter what we tried. @@ -274,23 +306,33 @@ def __set_readme_link(self): self.readme_link_button.setStyleSheet("border: 0px; background-color:rgba(0,0,0,0%);") if sys.platform == "linux": geometry = self.readme_link_button.geometry() - self.readme_link_button.setText("#DOC#") # In-button font has different width so "README" doesn't fit -.- - self.readme_link_button.setGeometry(QtCore.QRect(116, 220, geometry.width(), geometry.height())) + # In-button font has different width so "README" doesn't fit -.- + self.readme_link_button.setText("#DOC#") + self.readme_link_button.setGeometry( + QtCore.QRect(116, 220, geometry.width(), geometry.height()) + ) def __select_screenshot_directory(self): - self._autosplit_ref.settings_dict["screenshot_directory"] = QFileDialog.getExistingDirectory( - self, - "Select Screenshots Directory", + self._autosplit_ref.settings_dict["screenshot_directory"] = ( + QFileDialog.getExistingDirectory( + self, + "Select Screenshots Directory", + self._autosplit_ref.settings_dict["screenshot_directory"] + or self._autosplit_ref.settings_dict["split_image_directory"], + ) + ) + self.screenshot_directory_input.setText( self._autosplit_ref.settings_dict["screenshot_directory"] - or self._autosplit_ref.settings_dict["split_image_directory"], ) - self.screenshot_directory_input.setText(self._autosplit_ref.settings_dict["screenshot_directory"]) def __setup_bindings(self): """Hotkey initial values and bindings.""" for hotkey in HOTKEYS: hotkey_input: QtWidgets.QLineEdit = getattr(self, f"{hotkey}_input") - set_hotkey_hotkey_button: QtWidgets.QPushButton = getattr(self, f"set_{hotkey}_hotkey_button") + set_hotkey_hotkey_button: QtWidgets.QPushButton = getattr( + self, + f"set_{hotkey}_hotkey_button", + ) hotkey_input.setText(self._autosplit_ref.settings_dict.get(f"{hotkey}_hotkey", "")) set_hotkey_hotkey_button.clicked.connect(partial(set_hotkey, hotkey=hotkey)) @@ -299,17 +341,24 @@ def __setup_bindings(self): set_hotkey_hotkey_button.setEnabled(False) hotkey_input.setEnabled(False) -# region Set initial values + # region Set initial values + # Capture Settings self.fps_limit_spinbox.setValue(self._autosplit_ref.settings_dict["fps_limit"]) - self.live_capture_region_checkbox.setChecked(self._autosplit_ref.settings_dict["live_capture_region"]) + self.live_capture_region_checkbox.setChecked( + self._autosplit_ref.settings_dict["live_capture_region"] + ) self.capture_method_combobox.setCurrentIndex( CAPTURE_METHODS.get_index(self._autosplit_ref.settings_dict["capture_method"]), ) # No self.capture_device_combobox.setCurrentIndex # It'll set itself asynchronously in self.__set_all_capture_devices() - self.screenshot_directory_input.setText(self._autosplit_ref.settings_dict["screenshot_directory"]) - self.open_screenshot_checkbox.setChecked(self._autosplit_ref.settings_dict["open_screenshot"]) + self.screenshot_directory_input.setText( + self._autosplit_ref.settings_dict["screenshot_directory"] + ) + self.open_screenshot_checkbox.setChecked( + self._autosplit_ref.settings_dict["open_screenshot"] + ) # Image Settings self.default_comparison_method_combobox.setCurrentIndex( @@ -318,17 +367,27 @@ def __setup_bindings(self): self.default_similarity_threshold_spinbox.setValue( self._autosplit_ref.settings_dict["default_similarity_threshold"], ) - self.default_delay_time_spinbox.setValue(self._autosplit_ref.settings_dict["default_delay_time"]) - self.default_pause_time_spinbox.setValue(self._autosplit_ref.settings_dict["default_pause_time"]) + self.default_delay_time_spinbox.setValue( + self._autosplit_ref.settings_dict["default_delay_time"] + ) + self.default_pause_time_spinbox.setValue( + self._autosplit_ref.settings_dict["default_pause_time"] + ) self.loop_splits_checkbox.setChecked(self._autosplit_ref.settings_dict["loop_splits"]) - self.enable_auto_reset_image_checkbox.setChecked(self._autosplit_ref.settings_dict["enable_auto_reset"]) -# endregion -# region Binding + self.enable_auto_reset_image_checkbox.setChecked( + self._autosplit_ref.settings_dict["enable_auto_reset"] + ) + + # endregion + + # region Binding + # Capture Settings self.fps_limit_spinbox.valueChanged.connect(self.__fps_limit_changed) self.live_capture_region_checkbox.stateChanged.connect( lambda: user_profile.update_live_capture_region_setting( - self._autosplit_ref, self.live_capture_region_checkbox.isChecked(), + self._autosplit_ref, + self.live_capture_region_checkbox.isChecked(), ), ) self.capture_method_combobox.currentIndexChanged.connect( @@ -348,7 +407,9 @@ def __setup_bindings(self): ), ) self.default_similarity_threshold_spinbox.valueChanged.connect( - lambda: self.__update_default_threshold(self.default_similarity_threshold_spinbox.value()), + lambda: self.__update_default_threshold( + self.default_similarity_threshold_spinbox.value() + ), ) self.default_delay_time_spinbox.valueChanged.connect( lambda: self.__set_value("default_delay_time", self.default_delay_time_spinbox.value()), @@ -360,9 +421,13 @@ def __setup_bindings(self): lambda: self.__set_value("loop_splits", self.loop_splits_checkbox.isChecked()), ) self.enable_auto_reset_image_checkbox.stateChanged.connect( - lambda: self.__set_value("enable_auto_reset", self.enable_auto_reset_image_checkbox.isChecked()), + lambda: self.__set_value( + "enable_auto_reset", + self.enable_auto_reset_image_checkbox.isChecked(), + ), ) -# endregion + + # endregion def open_settings(autosplit: "AutoSplit"): @@ -381,7 +446,9 @@ def get_default_settings_from_ui(autosplit: "AutoSplit"): "skip_split_hotkey": default_settings_dialog.skip_split_input.text(), "pause_hotkey": default_settings_dialog.pause_input.text(), "screenshot_hotkey": default_settings_dialog.screenshot_input.text(), - "toggle_auto_reset_image_hotkey": default_settings_dialog.toggle_auto_reset_image_input.text(), + "toggle_auto_reset_image_hotkey": ( + default_settings_dialog.toggle_auto_reset_image_input.text() + ), "fps_limit": default_settings_dialog.fps_limit_spinbox.value(), "live_capture_region": default_settings_dialog.live_capture_region_checkbox.isChecked(), "capture_method": CAPTURE_METHODS.get_method_by_index( @@ -389,8 +456,12 @@ def get_default_settings_from_ui(autosplit: "AutoSplit"): ), "capture_device_id": default_settings_dialog.capture_device_combobox.currentIndex(), "capture_device_name": "", - "default_comparison_method": default_settings_dialog.default_comparison_method_combobox.currentIndex(), - "default_similarity_threshold": default_settings_dialog.default_similarity_threshold_spinbox.value(), + "default_comparison_method": ( + default_settings_dialog.default_comparison_method_combobox.currentIndex() + ), + "default_similarity_threshold": ( + default_settings_dialog.default_similarity_threshold_spinbox.value() + ), "default_delay_time": default_settings_dialog.default_delay_time_spinbox.value(), "default_pause_time": default_settings_dialog.default_pause_time_spinbox.value(), "loop_splits": default_settings_dialog.loop_splits_checkbox.isChecked(), diff --git a/src/region_selection.py b/src/region_selection.py index ac95fbde..7ab5f520 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -25,7 +25,12 @@ if sys.platform == "win32": import win32api import win32gui - from win32con import SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN + from win32con import ( + SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, + SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, + ) from winsdk._winrt import initialize_with_window # noqa: PLC2701 from winsdk.windows.foundation import AsyncStatus, IAsyncOperation from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker @@ -64,7 +69,9 @@ "All Files (" + " ".join([f"{extensions}" for _, extensions in SUPPORTED_IMREAD_FORMATS]) + ");;" - + ";;".join([f"{imread_format} ({extensions})" for imread_format, extensions in SUPPORTED_IMREAD_FORMATS]) + + ";;".join([ + f"{imread_format} ({extensions})" for imread_format, extensions in SUPPORTED_IMREAD_FORMATS + ]) ) @@ -243,7 +250,8 @@ def align_region(autosplit: "AutoSplit"): error_messages.alignment_not_matched() return - # The new region can be defined by using the min_loc point and the best_height and best_width of the template. + # The new region can be defined by using the min_loc point + # and the best_height and best_width of the template. __set_region_values( autosplit, x=autosplit.settings_dict["capture_region"]["x"] + best_loc[0], @@ -312,8 +320,9 @@ class BaseSelectWidget(QtWidgets.QWidget): def __init__(self): super().__init__() - # We need to pull the monitor information to correctly draw the geometry covering all portions - # of the user's screen. These parameters create the bounding box with left, top, width, and height + # We need to pull the monitor information to correctly draw + # the geometry covering all portions of the user's screen. + # These parameters create the bounding box with left, top, width, and height if sys.platform == "win32": x = win32api.GetSystemMetrics(SM_XVIRTUALSCREEN) y = win32api.GetSystemMetrics(SM_YVIRTUALSCREEN) @@ -385,7 +394,8 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent): def mouseReleaseEvent(self, event: QtGui.QMouseEvent): if self.__begin != self.__end: # The coordinates are pulled relative to the top left of the set geometry, - # so the added virtual screen offsets convert them back to the virtual screen coordinates + # so the added virtual screen offsets convert them back to the virtual + # screen coordinates left = min(self.__begin.x(), self.__end.x()) + self.geometry().x() top = min(self.__begin.y(), self.__end.y()) + self.geometry().y() right = max(self.__begin.x(), self.__end.x()) + self.geometry().x() diff --git a/src/split_parser.py b/src/split_parser.py index 96170cdd..08939fde 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -89,8 +89,8 @@ def delay_time_from_filename(filename: str): def loop_from_filename(filename: str): """ - Retrieve the number of loops from filename, if there is no loop number or the loop number isn't valid, - then 1 is returned. + Retrieve the number of loops from filename. + If there is no loop number or the loop number isn't valid, then 1 is returned. @param filename: String containing the file's name @return: A valid loop number, if not then 1 @@ -105,8 +105,8 @@ def loop_from_filename(filename: str): def comparison_method_from_filename(filename: str): """ - Retrieve the comparison method index from filename, if there is no comparison method or the index isn't valid, - then None is returned. + Retrieve the comparison method index from filename. + If there is no comparison method or the index isn't valid, then None is returned. @param filename: String containing the file's name @return: A valid comparison method index, if not then none @@ -174,10 +174,10 @@ def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): def validate_before_parsing( - autosplit: "AutoSplit", - *, - show_error: bool = True, - check_region_exists: bool = True, + autosplit: "AutoSplit", + *, + show_error: bool = True, + check_region_exists: bool = True, ): error = None split_image_directory = autosplit.settings_dict["split_image_directory"] @@ -198,8 +198,7 @@ def parse_and_validate_images(autosplit: "AutoSplit"): # Get split images all_images = [ AutoSplitImage(os.path.join(autosplit.settings_dict["split_image_directory"], image_name)) - for image_name - in os.listdir(autosplit.settings_dict["split_image_directory"]) + for image_name in os.listdir(autosplit.settings_dict["split_image_directory"]) ] # Find non-split images and then remove them from the list @@ -209,7 +208,8 @@ def parse_and_validate_images(autosplit: "AutoSplit"): error_message: Callable[[], object] | None = None - # If there is no start hotkey set but a Start Image is present, and is not auto controlled, throw an error. + # If there is no start hotkey set but a Start Image is present, + # and is not auto controlled, throw an error. if ( start_image and not autosplit.settings_dict["split_hotkey"] @@ -217,7 +217,8 @@ def parse_and_validate_images(autosplit: "AutoSplit"): ): error_message = error_messages.load_start_image - # If there is no reset hotkey set but a Reset Image is present, and is not auto controlled, throw an error. + # If there is no reset hotkey set but a Reset Image is present, + # and is not auto controlled, throw an error. elif ( reset_image and not autosplit.settings_dict["reset_hotkey"] diff --git a/src/user_profile.py b/src/user_profile.py index cdcb4623..c6a02873 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -9,7 +9,7 @@ import error_messages from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method from gen import design -from hotkeys import HOTKEYS, remove_all_hotkeys, set_hotkey +from hotkeys import HOTKEYS, Hotkey, remove_all_hotkeys, set_hotkey from menu_bar import open_settings from utils import auto_split_directory @@ -130,7 +130,8 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s try: with open(load_settings_file_path, encoding="utf-8") as file: # Casting here just so we can build an actual UserProfileDict once we're done validating - # Fallback to default settings if some are missing from the file. This happens when new settings are added. + # Fallback to default settings if some are missing from the file. + # This happens when new settings are added. loaded_settings = DEFAULT_PROFILE | cast(UserProfileDict, toml.load(file)) # TODO: Data Validation / fallbacks ? @@ -151,9 +152,13 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s for hotkey, hotkey_name in [(hotkey, f"{hotkey}_hotkey") for hotkey in HOTKEYS]: hotkey_value = autosplit.settings_dict.get(hotkey_name) if hotkey_value: - set_hotkey(autosplit, hotkey, hotkey_value) + # cast caused by a regression in pyright 1.1.365 + set_hotkey(autosplit, cast(Hotkey, hotkey), hotkey_value) - change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) + change_capture_method( + cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), + autosplit, + ) update_live_capture_region_setting(autosplit, autosplit.settings_dict["live_capture_region"]) autosplit.update_live_image_details(None) autosplit.reload_images_signal.emit(False) @@ -174,7 +179,10 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): "TOML (*.toml)", )[0] ) - if not (load_settings_file_path and __load_settings_from_file(autosplit, load_settings_file_path)): + if not ( + load_settings_file_path # fmt: skip + and __load_settings_from_file(autosplit, load_settings_file_path) + ): return autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path @@ -185,8 +193,8 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): def load_settings_on_open(autosplit: "AutoSplit"): settings_files = [ - file for file - in os.listdir(auto_split_directory) + file # fmt: skip + for file in os.listdir(auto_split_directory) if file.endswith(".toml") ] diff --git a/src/utils.py b/src/utils.py index 8b4c3c5e..f6411642 100644 --- a/src/utils.py +++ b/src/utils.py @@ -82,7 +82,10 @@ def is_valid_image(image: MatLike | None) -> TypeGuard[MatLike]: def is_valid_hwnd(hwnd: int): - """Validate the hwnd points to a valid window and not the desktop or whatever window obtained with `""`.""" + """ + Validate the hwnd points to a valid window + and not the desktop or whatever window obtained with `""`. + """ if not hwnd: return False if sys.platform == "win32": @@ -147,18 +150,25 @@ def get_direct3d_device(): if sys.platform != "win32": raise OSError("Direct3D Device is only available on Windows") - # Note: Must create in the same thread (can't use a global) otherwise when ran from LiveSplit it will raise: + # Note: Must create in the same thread (can't use a global) + # otherwise when ran from LiveSplit it will raise: # OSError: The application called an interface that was marshalled for a different thread media_capture = MediaCapture() async def init_mediacapture(): await media_capture.initialize_async() + asyncio.run(init_mediacapture()) - direct_3d_device = media_capture.media_capture_settings and media_capture.media_capture_settings.direct3_d11_device + direct_3d_device = ( + media_capture.media_capture_settings + and media_capture.media_capture_settings.direct3_d11_device + ) if not direct_3d_device: try: # May be problematic? https://github.com/pywinrt/python-winsdk/issues/11#issuecomment-1315345318 - direct_3d_device = LearningModelDevice(LearningModelDeviceKind.DIRECT_X_HIGH_PERFORMANCE).direct3_d11_device + direct_3d_device = LearningModelDevice( + LearningModelDeviceKind.DIRECT_X_HIGH_PERFORMANCE + ).direct3_d11_device # TODO: Unknown potential error, I don't have an older Win10 machine to test. except BaseException: # noqa: S110,BLE001 pass @@ -191,8 +201,9 @@ def fire_and_forget(func: Callable[..., Any]): """ Runs synchronous function asynchronously without waiting for a response. - Uses threads on Windows because ~~`RuntimeError: There is no current event loop in thread 'MainThread'.`~~ - Because maybe asyncio has issues. Unsure. See alpha.5 and https://github.com/Avasam/AutoSplit/issues/36 + Uses threads on Windows because + ~~`RuntimeError: There is no current event loop in thread 'MainThread'.`~~ + maybe asyncio has issues. Unsure. See alpha.5 and https://github.com/Avasam/AutoSplit/issues/36 Uses asyncio on Linux because of a `Segmentation fault (core dumped)` """