From f4ee20959721180cd7d2496742761aef38c50c42 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 29 Oct 2022 12:40:18 -0400 Subject: [PATCH 01/15] Point python_grabber to andreaschiavinato --- scripts/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/requirements.txt b/scripts/requirements.txt index d425896f..96b20edb 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -19,7 +19,7 @@ packaging Pillow>=7.2.0 # https://github.com/SerpentAI/D3DShot/issues/44 psutil PyAutoGUI -git+https://github.com/Avasam/python_grabber.git@complete-types#egg=pygrabber # https://github.com/andreaschiavinato/python_grabber/pull/18 +git+https://github.com/andreaschiavinato/python_grabber.git#egg=pygrabber # Completed types PyQt6>=6.2.1 # Python 3.10 support requests toml From 627c23be5613e4bbad95d58942accc5cadfd233a Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 13 Nov 2022 18:48:03 -0500 Subject: [PATCH 02/15] Added summary of histogram --- README.md | 1 + res/settings.ui | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index a27a31ec..2b32e120 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ This program can be used to automatically start, split, and reset your preferred - 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. It is very inaccurate. #### Capture Method diff --git a/res/settings.ui b/res/settings.ui index ecc0a9d7..d1750c6e 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -200,6 +200,8 @@ 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 From a9341db586ba23ff9ab6e5bc54506de083c6890e Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 13:52:04 -0500 Subject: [PATCH 03/15] Backport changes from linux branch --- .flake8 | 1 + .github/workflows/lint-and-build.yml | 5 + .vscode/settings.json | 5 +- README.md | 15 +- build instructions.md | 24 +++ pyproject.toml | 16 +- res/about.ui | 4 +- res/design.ui | 16 +- res/settings.ui | 4 +- scripts/build.ps1 | 16 +- scripts/install.ps1 | 4 +- scripts/requirements-dev.txt | 4 +- scripts/requirements.txt | 19 +- src/AutoSplit.py | 13 +- src/AutoSplitImage.py | 3 +- src/capture_method/BitBltCaptureMethod.py | 1 + src/capture_method/CaptureMethodBase.py | 2 + .../DesktopDuplicationCaptureMethod.py | 1 - .../VideoCaptureDeviceCaptureMethod.py | 4 +- src/capture_method/__init__.py | 178 +++++++++--------- src/compare.py | 9 +- src/error_messages.py | 23 ++- src/hotkeys.py | 88 +++++---- src/menu_bar.py | 14 +- src/region_selection.py | 47 +++-- src/user_profile.py | 5 +- src/utils.py | 51 ++++- 27 files changed, 331 insertions(+), 241 deletions(-) create mode 100644 build instructions.md diff --git a/.flake8 b/.flake8 index f6b15399..08a25d2f 100644 --- a/.flake8 +++ b/.flake8 @@ -5,6 +5,7 @@ max-line-length=120 exclude=src/gen/, typings/cv2-stubs/__init__.pyi ignore= W503, ; Linebreak before binary operator + E124, ; Closing bracket may not match multi-line method invocation style (enforced by add-trailing-comma) E402, ; Allow imports at the bottom of file Y026, ; Not using typing_extensions SIM105, ; contextlib.suppress is roughly 3x slower than try/except diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 1d43a35c..47c6c80a 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -47,6 +47,7 @@ jobs: runs-on: windows-latest strategy: fail-fast: false + # Pyright is version and platform sensible matrix: python-version: ["3.9", "3.10"] steps: @@ -68,6 +69,7 @@ jobs: runs-on: windows-latest strategy: fail-fast: false + # Pylint is version and platform sensible matrix: python-version: ["3.9", "3.10"] steps: @@ -87,6 +89,7 @@ jobs: runs-on: windows-latest strategy: fail-fast: false + # Flake8 is tied to the version of Python on which it runs. Platform checks are ignored matrix: python-version: ["3.9", "3.10"] steps: @@ -103,6 +106,7 @@ jobs: - name: Analysing the code with Flake8 run: flake8 src/ typings/ Bandit: + # Bandit only matters on the version deployed. Platform checks are ignored runs-on: windows-latest steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} @@ -121,6 +125,7 @@ jobs: runs-on: windows-latest strategy: fail-fast: false + # Only the Python version we plan on shipping matters. matrix: python-version: ["3.10"] steps: diff --git a/.vscode/settings.json b/.vscode/settings.json index c5b9d108..99fc7ef8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,12 +47,14 @@ "*.lock": true, }, "[python]": { + // Cannot use autotpep8 until https://github.com/microsoft/vscode-autopep8/issues/32 is fixed + "editor.defaultFormatter": "ms-python.python", "editor.tabSize": 4, "editor.rulers": [ 72, // PEP8-17 docstrings // 79, // PEP8-17 default max // 88, // Black default - 99, // PEP8-17 acceptable max + // 99, // PEP8-17 acceptable max 120, // Our hard rule ], }, @@ -115,6 +117,7 @@ "powershell.codeFormatting.useCorrectCasing": true, "powershell.codeFormatting.whitespaceBetweenParameters": true, "powershell.integratedConsole.showOnStartup": false, + "terminal.integrated.defaultProfile.windows": "PowerShell", "xml.codeLens.enabled": true, "xml.format.spaceBeforeEmptyCloseTag": false, } diff --git a/README.md b/README.md index 2b32e120..6f21235c 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,8 @@ This program can be used to automatically start, split, and reset your preferred ### Building -(This is not required for normal use) - -- Python 3.9 - 3.10. -- 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/). - -- Read [requirements.txt](/scripts/requirements.txt) for more information on how to install, run and build the python code. - - Run `./scripts/install.ps1` to install all dependencies. - - Run the app directly with `./scripts/start.ps1 [--auto-controlled]`. - - Run `./scripts/build.ps1` to build an executable. -- Recompile resources after modifications by running `./scripts/compile_resources.ps1`. -- All configured for VSCode, including Run (F5) and Build (Ctrl+Shift+B) commands. +(This is not required for normal use) +Refer to the [build instructions](build%20instructions.md) if you'd like to build the application yourself or run it directly in Python. ## OPTIONS @@ -225,7 +216,7 @@ The AutoSplit LiveSplit Component will directly connect AutoSplit with LiveSplit - 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.exe) and Profile Path (path to your AutoSplit `.toml` profile file) respectively. +- 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. diff --git a/build instructions.md b/build instructions.md new file mode 100644 index 00000000..05cd53ee --- /dev/null +++ b/build instructions.md @@ -0,0 +1,24 @@ +# Install and Build instructions + +## Requirements + +### Windows + +- 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/). + +### All platforms + +- [Python](https://www.python.org/downloads/) 3.9+. +- [Node](https://nodejs.org) is optional, but required for complete linting. Should be installed automatically. +- [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! + +## Install and Build steps + +- Read [requirements.txt](/scripts/requirements.txt) for more information on how to install, run and build the python code. + - Run `./scripts/install.ps1` to install all dependencies. + - Run the app directly with `./scripts/start.ps1 [--auto-controlled]`. + - Or debug by pressing `F5` in VSCode + - Run `./scripts/build.ps1` or press `CTRL+Shift+B` in VSCode to build an executable. +- Recompile resources after modifications by running `./scripts/compile_resources.ps1`. diff --git a/pyproject.toml b/pyproject.toml index 06a851a6..66bd924d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,12 @@ # https://github.com/hhatto/autopep8#more-advanced-usage [tool.autopep8] max_line_length = 120 -recursive = true +# recursive = true aggressive = 3 -ignore = ["E70"] # Allow ... on same line as def +ignore = [ + "E124", # Closing bracket may not match multi-line method invocation style (enforced by add-trailing-comma) + "E70" # Allow ... on same line as def +] # https://github.com/microsoft/pyright/blob/main/docs/configuration.md#sample-pyprojecttoml-file @@ -105,13 +108,6 @@ max-branches = 15 valid-classmethod-first-arg = "self" # https://pylint.pycqa.org/en/latest/user_guide/options.html#naming-styles module-naming-style = "any" -# Can't make private class with PascalCase -class-rgx = "_?_?[a-zA-Z]+?$" -good-names = [ - # PyQt methods - "closeEvent", "paintEvent", "keyPressEvent", "mousePressEvent", "mouseMoveEvent", "mouseReleaseEvent", - # https://github.com/PyCQA/pylint/issues/2018 - "id", "x", "y", "a0", "i", "t0", "t1"] disable = [ # No need to mention the fixmes "fixme", @@ -121,6 +117,8 @@ disable = [ "unused-import", "wrong-import-order", "wrong-import-position", + # Already taken care of by Flake8-naming, which does a better job + "invalid-name", # Already taken care of and grayed out. Also conflicts with Pylance reportIncompatibleMethodOverride "unused-argument", # Only reports a single instance. Pyright does a better job anyway diff --git a/res/about.ui b/res/about.ui index 04ff482c..481ae936 100644 --- a/res/about.ui +++ b/res/about.ui @@ -32,7 +32,9 @@ - :/resources/icon.ico:/resources/icon.ico + :/resources/icon.ico + :/resources/icon.ico + diff --git a/res/design.ui b/res/design.ui index 03aa78f7..0e80cd89 100644 --- a/res/design.ui +++ b/res/design.ui @@ -32,7 +32,9 @@ - :/resources/icon.ico:/resources/icon.ico + :/resources/icon.ico + :/resources/icon.ico + @@ -47,6 +49,9 @@ X + + Qt::AlignCenter + @@ -244,6 +249,9 @@ Width + + Qt::AlignCenter + @@ -257,6 +265,9 @@ Height + + Qt::AlignCenter + @@ -425,6 +436,9 @@ Y + + Qt::AlignCenter + diff --git a/res/settings.ui b/res/settings.ui index d1750c6e..cec1678a 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -32,7 +32,9 @@ - :/resources/icon.ico:/resources/icon.ico + :/resources/icon.ico + :/resources/icon.ico + diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 0dd9aae5..840db0c2 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,8 +1,10 @@ & "$PSScriptRoot/compile_resources.ps1" -pyinstaller ` - --onefile ` - --windowed ` - --additional-hooks-dir=Pyinstaller/hooks ` - --icon=res/icon.ico ` - --splash=res/splash.png ` - "$PSScriptRoot/../src/AutoSplit.py" + +$arguments = @( + '--onefile', + '--windowed', + '--additional-hooks-dir=Pyinstaller/hooks', + '--icon=res/icon.ico', + '--splash=res/splash.png') + +pyinstaller $arguments "$PSScriptRoot/../src/AutoSplit.py" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0bbf770c..a4cfe071 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -7,9 +7,9 @@ If ($IsWindows) { # Installing Python dependencies $dev = If ($env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } -# Ensures installation tools are up to date. +# Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. python3 -m pip install wheel pip setuptools --upgrade -python3 -m pip install -r "$PSScriptRoot/requirements$dev.txt" +pip install -r "$PSScriptRoot/requirements$dev.txt" # 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 eab2e420..d8b5eee9 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -7,7 +7,7 @@ -r requirements.txt # # Linters and formatters -add-trailing-comma +add-trailing-comma>=2.3.0 # Added support for with statement bandit flake8>=5 # flake8-pyi deprecation warnings flake8-builtins @@ -15,10 +15,10 @@ flake8-bugbear flake8-class-attributes-order flake8-comprehensions>=3.8 # flake8 5 support flake8-datetimez -flake8-isort>=4.2 # flake8 5 support flake8-pyi>=22.10.0 # Fixes for negative numbers flake8-quotes flake8-simplify +isort pep8-naming pylint>=2.14,<3.0.0 # New checks # 3.0 still in pre-release pyright>=1.1.276 # Typeshed update diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 96b20edb..2ce61e47 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -12,23 +12,26 @@ # Dependencies: certifi ImageHash>=4.3.1 # Contains type information + setup as package not module -keyboard +git+https://github.com/Avasam/keyboard.git@fix-563#egg=keyboard # Fix install on linux-ci https://github.com/boppreh/keyboard/pull/568 numpy>=1.23 # Updated types opencv-python-headless>=4.6 # Breaking changes importing cv2.cv2 packaging -Pillow>=7.2.0 # https://github.com/SerpentAI/D3DShot/issues/44 +Pillow>=9.2 # gnome-screeshot checks psutil PyAutoGUI -git+https://github.com/andreaschiavinato/python_grabber.git#egg=pygrabber # Completed types PyQt6>=6.2.1 # Python 3.10 support requests toml -# Windows-only -git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot # D3DShot from PyPI with Pillow>=7.2.0 will install 0.1.3 instead of 0.1.5 -pywin32>=301 -winsdk>=v1.0.0b4 # # Build and compile resources PyInstaller>=5.2 # opencv-python 4.6 support pyinstaller-hooks-contrib>=2022.9 # opencv-python 4.6 support. Changes for pywintypes and comtypes -PySide6 +PySide6>=6.2.2 # Apple silicon support +# +# https://peps.python.org/pep-0508/#environment-markers +# +# Windows-only dependencies: +git+https://github.com/andreaschiavinato/python_grabber.git#egg=pygrabber ; sys_platform == 'win32' # Completed types +pywin32>=301 ; sys_platform == 'win32' +winsdk>=v1.0.0b4 ; sys_platform == 'win32' +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 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 0a1298c2..ca9ac92e 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -32,7 +32,7 @@ from user_profile import DEFAULT_PROFILE from utils import ( AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, FROZEN, START_AUTO_SPLITTER_TEXT, WINDOWS_BUILD_NUMBER, auto_split_directory, - decimal, is_valid_image, + decimal, is_valid_image, open_file, ) CHECK_FPS_ITERATIONS = 10 @@ -43,7 +43,7 @@ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) -class AutoSplit(QMainWindow, design.Ui_MainWindow): +class AutoSplit(QMainWindow, design.Ui_MainWindow): # pylint: disable=too-many-instance-attributes # Parse command line args is_auto_controlled = "--auto-controlled" in sys.argv @@ -103,7 +103,6 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s # Setup global error handling self.show_error_signal.connect(lambda errorMessageBox: errorMessageBox()) - # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors sys.excepthook = error_messages.make_excepthook(self) self.setupUi(self) @@ -384,9 +383,9 @@ def __take_screenshot(self): error_messages.region() return - # save and open image + # Save and open image cv2.imwrite(screenshot_path, capture) - os.startfile(screenshot_path) # nosec + open_file(screenshot_path) def __check_fps(self): self.fps_value_label.setText("...") @@ -761,7 +760,8 @@ def __get_capture_for_comparison(self): """ capture, is_old_image = self.capture_method.get_frame(self) - # This most likely means we lost capture (ie the captured window was closed, crashed, etc.) + # 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: @@ -842,6 +842,7 @@ def closeEvent(self, a0: QtGui.QCloseEvent | None = None): def exit_program(): if self.update_auto_control: self.update_auto_control.terminate() + self.capture_method.close(self) if a0 is not None: a0.accept() if self.is_auto_controlled: diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py index cd65bb7f..8988b81c 100644 --- a/src/AutoSplitImage.py +++ b/src/AutoSplitImage.py @@ -6,11 +6,10 @@ import cv2 import numpy as np -from win32con import MAXBYTE import error_messages from compare import check_if_image_has_transparency, compare_histograms, compare_l2_norm, compare_phash -from utils import is_valid_image +from utils import MAXBYTE, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index a8a0bc4a..9eca085b 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -28,6 +28,7 @@ def get_frame(self, autosplit: AutoSplit) -> tuple[cv2.Mat | None, bool]: selection = autosplit.settings_dict["capture_region"] hwnd = autosplit.hwnd image: cv2.Mat | None = None + if not self.check_selected_region_exists(autosplit): return None, False diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index 0c5057c8..e75caf07 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -12,6 +12,7 @@ class CaptureMethodBase(): def __init__(self, autosplit: AutoSplit | None = None): + # Some capture methods don't need an initialization process pass def reinitialize(self, autosplit: AutoSplit): @@ -19,6 +20,7 @@ def reinitialize(self, autosplit: AutoSplit): self.__init__(autosplit) # pylint: disable=unnecessary-dunder-call def close(self, autosplit: AutoSplit): + # Some capture methods don't need an initialization process pass def get_frame(self, autosplit: AutoSplit) -> tuple[cv2.Mat | None, bool]: diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index 74452fc9..07706501 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -1,7 +1,6 @@ from __future__ import annotations import ctypes -import ctypes.wintypes from typing import TYPE_CHECKING, cast import cv2 diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index f15fe082..3a2843f1 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -63,10 +63,10 @@ def __read_loop(self, autosplit: AutoSplit): self.capture_device.release() autosplit.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, - error, ), ) @@ -98,7 +98,6 @@ def close(self, autosplit: AutoSplit): self.capture_device.release() def get_frame(self, autosplit: AutoSplit): - selection = autosplit.settings_dict["capture_region"] if not self.check_selected_region_exists(autosplit): return None, False @@ -108,6 +107,7 @@ def get_frame(self, autosplit: AutoSplit): if not is_valid_image(image): return None, is_old_image + selection = autosplit.settings_dict["capture_region"] # Ensure we can't go OOB of the image y = min(selection["y"], image.shape[0] - 1) x = min(selection["x"], image.shape[1] - 1) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index d8de71e0..71b129c2 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -4,9 +4,9 @@ from collections import OrderedDict from dataclasses import dataclass from enum import Enum, EnumMeta, unique -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, TypedDict, cast -from pygrabber import dshow_graph +from pygrabber.dshow_graph import FilterGraph from capture_method.BitBltCaptureMethod import BitBltCaptureMethod from capture_method.CaptureMethodBase import CaptureMethodBase @@ -14,7 +14,7 @@ from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod -from utils import WINDOWS_BUILD_NUMBER, get_direct3d_device +from utils import WINDOWS_BUILD_NUMBER, first, try_get_direct3d_device if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -75,27 +75,38 @@ def __hash__(self): class CaptureMethodDict(OrderedDict[CaptureMethodEnum, CaptureMethodInfo]): + def get_index(self, capture_method: str | CaptureMethodEnum): + """ + Returns 0 if the capture_method is invalid or unsupported + """ + try: + return list(self.keys()).index(cast(CaptureMethodEnum, capture_method)) + except ValueError: + return 0 def get_method_by_index(self, index: int): + """ + Returns the `CaptureMethodEnum` at index. + If index is invalid, returns the first (default) `CaptureMethodEnum`. + Returns `CaptureMethodEnum.NONE` if there are no capture methods available. + """ if len(self) <= 0: return CaptureMethodEnum.NONE - if index < 0: - return next(iter(self)) + if index <= 0: + return first(self) return list(self.keys())[index] - - def __getitem__(self, key: CaptureMethodEnum): - if key == CaptureMethodEnum.NONE: + if TYPE_CHECKING: # noqa: CCE002 + __getitem__ = None # pyright: ignore[reportGeneralTypeIssues] # Disallow unsafe get + + def get(self, __key: CaptureMethodEnum): + """ + Returns the `CaptureMethodInfo` for `CaptureMethodEnum` if `CaptureMethodEnum` is available, + else defaults to the first available `CaptureMethodEnum`. + Returns the `CaptureMethodBase` (default) implementation if there's no capture methods. + """ + if __key == CaptureMethodEnum.NONE or len(self) <= 0: return NONE_CAPTURE_METHOD - try: - return super().__getitem__(key) - # If requested method does not exists... - except KeyError: - try: - # ...fallback to the first one - return super().__getitem__(self.get_method_by_index(0)) - except KeyError: - # ...fallback to an empty capture method to avoid crashes - return NONE_CAPTURE_METHOD + return super().get(__key, first(self.values())) NONE_CAPTURE_METHOD = CaptureMethodInfo( @@ -105,19 +116,24 @@ def __getitem__(self, key: CaptureMethodEnum): implementation=CaptureMethodBase, ) -CAPTURE_METHODS = CaptureMethodDict({ - CaptureMethodEnum.BITBLT: CaptureMethodInfo( - name="BitBlt", - short_description="fastest, least compatible", - description=( - "\nA good default fast option. But it cannot properly record " - "\nOpenGL, Hardware Accelerated or Exclusive Fullscreen windows. " - "\nThe smaller the selected region, the more efficient it is. " - ), - - implementation=BitBltCaptureMethod, +CAPTURE_METHODS = CaptureMethodDict() +CAPTURE_METHODS[CaptureMethodEnum.BITBLT] = CaptureMethodInfo( + name="BitBlt", + short_description="fastest, least compatible", + description=( + "\nA good default fast option. But it cannot properly record " + "\nOpenGL, Hardware Accelerated or Exclusive Fullscreen windows. " + "\nThe smaller the selected region, the more efficient it is. " ), - CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE: CaptureMethodInfo( + + implementation=BitBltCaptureMethod, +) +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] = CaptureMethodInfo( name="Windows Graphics Capture", short_description="fast, most compatible, capped at 60fps", description=( @@ -129,63 +145,46 @@ def __getitem__(self, key: CaptureMethodEnum): "\nCaps at around 60 FPS. " ), implementation=WindowsGraphicsCaptureMethod, + ) +CAPTURE_METHODS[CaptureMethodEnum.DESKTOP_DUPLICATION] = CaptureMethodInfo( + name="Direct3D Desktop Duplication", + short_description="slower, bound to display", + description=( + "\nDuplicates the desktop using Direct3D. " + "\nIt can record OpenGL and Hardware Accelerated windows. " + "\nAbout 10-15x slower than BitBlt. Not affected by window size. " + "\nOverlapping windows will show up and can't record across displays. " ), - CaptureMethodEnum.DESKTOP_DUPLICATION: CaptureMethodInfo( - name="Direct3D Desktop Duplication", - short_description="slower, bound to display", - description=( - "\nDuplicates the desktop using Direct3D. " - "\nIt can record OpenGL and Hardware Accelerated windows. " - "\nAbout 10-15x slower than BitBlt. Not affected by window size. " - "\nOverlapping windows will show up and can't record across displays. " - ), - implementation=DesktopDuplicationCaptureMethod, - ), - CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT: CaptureMethodInfo( - name="Force Full Content Rendering", - short_description="very slow, can affect rendering pipeline", - description=( - "\nUses BitBlt behind the scene, but passes a special flag " - "\nto PrintWindow to force rendering the entire desktop. " - "\nAbout 10-15x slower than BitBlt based on original window size " - "\nand can mess up some applications' rendering pipelines. " - ), - implementation=ForceFullContentRenderingCaptureMethod, + implementation=DesktopDuplicationCaptureMethod, +) +CAPTURE_METHODS[CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT] = CaptureMethodInfo( + name="Force Full Content Rendering", + short_description="very slow, can affect rendering pipeline", + description=( + "\nUses BitBlt behind the scene, but passes a special flag " + "\nto PrintWindow to force rendering the entire desktop. " + "\nAbout 10-15x slower than BitBlt based on original window size " + "\nand can mess up some applications' rendering pipelines. " ), - CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: CaptureMethodInfo( - name="Video Capture Device", - short_description="see below", - description=( - "\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. " - "\nYou can select one below. " - "\nThere are currently performance issues, but it might be more convenient. " - "\nIf you want to use this with OBS' Virtual Camera, use the Virtualcam plugin instead " - "\nhttps://github.com/Avasam/obs-virtual-cam/releases" - ), - implementation=VideoCaptureDeviceCaptureMethod, + implementation=ForceFullContentRenderingCaptureMethod, +) +CAPTURE_METHODS[CaptureMethodEnum.VIDEO_CAPTURE_DEVICE] = CaptureMethodInfo( + name="Video Capture Device", + short_description="see below", + description=( + "\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. " + "\nYou can select one below. " + "\nThere are currently performance issues, but it might be more convenient. " + "\nIf you want to use this with OBS' Virtual Camera, use the Virtualcam plugin instead " + "\nhttps://github.com/Avasam/obs-virtual-cam/releases" ), -}) - - -def try_get_direct3d_device(): - try: - return get_direct3d_device() - except OSError: - return None - - -# Detect and remove unsupported capture methods -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 - or not try_get_direct3d_device() -): - CAPTURE_METHODS.pop(CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE) + implementation=VideoCaptureDeviceCaptureMethod, +) def change_capture_method(selected_capture_method: CaptureMethodEnum, autosplit: AutoSplit): autosplit.capture_method.close(autosplit) - autosplit.capture_method = CAPTURE_METHODS[selected_capture_method].implementation(autosplit) + autosplit.capture_method = CAPTURE_METHODS.get(selected_capture_method).implementation(autosplit) if selected_capture_method == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: autosplit.select_region_button.setDisabled(True) autosplit.select_window_button.setDisabled(True) @@ -200,12 +199,19 @@ class CameraInfo(): name: str occupied: bool backend: str - size: tuple[int, int] + resolution: tuple[int, int] | None + + +def get_input_device_resolution(index: int): + filter_graph = FilterGraph() + filter_graph.add_video_input_device(index) + resolution = filter_graph.get_input_device().get_current_format() + filter_graph.remove_filters() + return resolution async def get_all_video_capture_devices() -> list[CameraInfo]: - filter_graph = dshow_graph.FilterGraph() - named_video_inputs = filter_graph.get_input_devices() + named_video_inputs = FilterGraph().get_input_devices() async def get_camera_info(index: int, device_name: str): backend = "" @@ -225,10 +231,8 @@ async def get_camera_info(index: int, device_name: str): # else None # finally: # video_capture.release() - filter_graph.add_video_input_device(index) - size = filter_graph.get_input_device().get_current_format() - filter_graph.remove_filters() - return CameraInfo(index, device_name, False, backend, size) + + return CameraInfo(index, device_name, False, backend, get_input_device_resolution(index)) future = asyncio.gather( *[ diff --git a/src/compare.py b/src/compare.py index 445bdff0..cac03ec1 100644 --- a/src/compare.py +++ b/src/compare.py @@ -1,11 +1,12 @@ from __future__ import annotations +from math import sqrt + import cv2 import imagehash from PIL import Image -from win32con import MAXBYTE -from utils import is_valid_image +from utils import MAXBYTE, is_valid_image MAXRANGE = MAXBYTE + 1 CHANNELS = [0, 1, 2] @@ -46,9 +47,9 @@ def compare_l2_norm(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = No error = cv2.norm(source, capture, cv2.NORM_L2, mask) # The L2 Error is summed across all pixels, so this normalizes - max_error: float = (source.size ** 0.5) * MAXBYTE \ + max_error = sqrt(source.size) * MAXBYTE \ if not is_valid_image(mask)\ - else (3 * cv2.countNonZero(mask) * MAXBYTE * MAXBYTE) ** 0.5 + else sqrt(cv2.countNonZero(mask) * MASK_SIZE_MULTIPLIER) if not max_error: return 0.0 diff --git a/src/error_messages.py b/src/error_messages.py index fba1024d..81e2cbfb 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -115,13 +115,15 @@ 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 AutoSplit.exe") + 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 AutoSplit.exe", + + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable.", ) @@ -149,7 +151,10 @@ def already_running(): ) -def exception_traceback(message: str, exception: BaseException): +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__)), @@ -176,21 +181,15 @@ def excepthook(exception_type: type[BaseException], exception: BaseException, _t ): return # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors - autosplit.show_error_signal.emit( - lambda: exception_traceback( - "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}", - exception, - ), - ) + autosplit.show_error_signal.emit(lambda: exception_traceback(exception)) return excepthook def handle_top_level_exceptions(exception: Exception): - message = f"AutoSplit encountered an unrecoverable exception and will now close. {CREATE_NEW_ISSUE_MESSAGE}" + 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(message, exception) + exception_traceback(exception, message) else: traceback.print_exception(type(exception), exception, exception.__traceback__) sys.exit(1) diff --git a/src/hotkeys.py b/src/hotkeys.py index 814663b7..095fa47b 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -7,7 +7,7 @@ import pyautogui from PyQt6 import QtWidgets -from error_messages import invalid_hotkey +import error_messages from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit if TYPE_CHECKING: @@ -24,6 +24,10 @@ HOTKEYS: list[Hotkey] = ["split", "reset", "skip_split", "undo_split", "pause", "toggle_auto_reset_image"] +def remove_all_hotkeys(): + keyboard.unhook_all() + + def before_setting_hotkey(autosplit: AutoSplit): """ Do all of these after you click "Set Hotkey" but before you type the hotkey @@ -237,45 +241,51 @@ def set_hotkey(autosplit: AutoSplit, hotkey: Hotkey, preselected_hotkey_name: st # Disable some buttons before_setting_hotkey(autosplit) - # New thread points to callback. this thread is needed or GUI will freeze + # New thread points to read_and_set_hotkey. this thread is needed or GUI will freeze # while the program waits for user input on the hotkey @fire_and_forget - def callback(): - hotkey_name = preselected_hotkey_name if preselected_hotkey_name else __read_hotkey() - - if not is_valid_hotkey_name(hotkey_name): - autosplit.show_error_signal.emit(lambda: invalid_hotkey(hotkey_name)) - return - - # Try to remove the previously set hotkey if there is one - _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - # Remove any hotkey using the same key combination - - __remove_key_already_set(autosplit, hotkey_name) - - action = __get_hotkey_action(autosplit, hotkey) - setattr( - autosplit, - f"{hotkey}_hotkey", - # 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.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. - # 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 - else keyboard.hook_key( - hotkey_name, - lambda keyboard_event: _hotkey_action(keyboard_event, hotkey_name, action), - ), - ) + def read_and_set_hotkey(): + try: + hotkey_name = preselected_hotkey_name if preselected_hotkey_name else __read_hotkey() - if autosplit.SettingsWidget: - getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) - autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name - autosplit.after_setting_hotkey_signal.emit() + if not is_valid_hotkey_name(hotkey_name): + autosplit.show_error_signal.emit(lambda: error_messages.invalid_hotkey(hotkey_name)) + return - callback() + # Try to remove the previously set hotkey if there is one + _unhook(getattr(autosplit, f"{hotkey}_hotkey")) + # Remove any hotkey using the same key combination + + __remove_key_already_set(autosplit, hotkey_name) + + action = __get_hotkey_action(autosplit, hotkey) + setattr( + autosplit, + f"{hotkey}_hotkey", + # 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.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. + # 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 + else keyboard.hook_key( + hotkey_name, + lambda keyboard_event: _hotkey_action(keyboard_event, hotkey_name, action), + ), + ) + + if autosplit.SettingsWidget: + getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) + autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name + autosplit.after_setting_hotkey_signal.emit() + except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here + error = exception + autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(error)) + finally: + autosplit.after_setting_hotkey_signal.emit() + + read_and_set_hotkey() diff --git a/src/menu_bar.py b/src/menu_bar.py index fddf36a0..4fc060bd 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -112,16 +112,6 @@ def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): autosplit.CheckForUpdatesThread.start() -def get_capture_method_index(capture_method: str | CaptureMethodEnum): - """ - Returns 0 if the capture_method is invalid or unsupported - """ - try: - return list(CAPTURE_METHODS.keys()).index(cast(CaptureMethodEnum, capture_method)) - except ValueError: - return 0 - - class __SettingsWidget(QtWidgets.QWidget, settings_ui.Ui_SettingsWidget): __video_capture_devices: list[CameraInfo] = [] """ @@ -257,7 +247,7 @@ def hotkey_connect(hotkey: Hotkey): set_hotkey_hotkey_button.clicked.connect(hotkey_connect(hotkey)) # Make it very clear that hotkeys are not used when auto-controlled - if autosplit.is_auto_controlled: + if autosplit.is_auto_controlled and hotkey != "toggle_auto_reset_image": set_hotkey_hotkey_button.setEnabled(False) hotkey_input.setEnabled(False) @@ -266,7 +256,7 @@ def hotkey_connect(hotkey: Hotkey): self.fps_limit_spinbox.setValue(autosplit.settings_dict["fps_limit"]) self.live_capture_region_checkbox.setChecked(autosplit.settings_dict["live_capture_region"]) self.capture_method_combobox.setCurrentIndex( - get_capture_method_index(autosplit.settings_dict["capture_method"]), + CAPTURE_METHODS.get_index(autosplit.settings_dict["capture_method"]), ) self.capture_device_combobox.currentIndexChanged.connect( lambda: self.__set_value("capture_device_id", self.__capture_device_changed()), diff --git a/src/region_selection.py b/src/region_selection.py index 9bea31df..7ae32407 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -4,24 +4,27 @@ import ctypes.wintypes import os from math import ceil -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import cv2 import numpy as np from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtTest import QTest from win32 import win32gui -from win32con import GA_ROOT, MAXBYTE, 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 from winsdk.windows.foundation import AsyncStatus, IAsyncOperation from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker import error_messages -from utils import get_window_bounds, is_valid_hwnd, is_valid_image +from utils import MAXBYTE, get_window_bounds, getTopWindowAt, is_valid_hwnd, is_valid_image + +user32 = ctypes.windll.user32 if TYPE_CHECKING: from AutoSplit import AutoSplit + SUPPORTED_IMREAD_FORMATS = [ ("Windows bitmaps", "*.bmp *.dib"), ("JPEG files", "*.jpeg *.jpg *.jpe"), @@ -41,8 +44,6 @@ + ");;"\ + ";;".join([f"{imread_format} ({extensions})" for imread_format, extensions in SUPPORTED_IMREAD_FORMATS]) -user32 = ctypes.windll.user32 - def __select_graphics_item(autosplit: AutoSplit): # pyright: ignore [reportUnusedFunction] # TODO: For later as a different picker option @@ -88,7 +89,12 @@ def select_region(autosplit: AutoSplit): QTest.qWait(1) del selector - hwnd, window_text = __get_window_from_point(x, y) + window = getTopWindowAt(x, y) + if not window: + error_messages.region() + return + hwnd = window.getHandle() + window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() return @@ -99,10 +105,12 @@ def select_region(autosplit: AutoSplit): 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 __set_region_values( autosplit, - left=x - window_x - left_bounds, - top=y - window_y - top_bounds, + left=x - offset_x, + top=y - offset_y, width=width, height=height, ) @@ -122,7 +130,12 @@ def select_window(autosplit: AutoSplit): QTest.qWait(1) del selector - hwnd, window_text = __get_window_from_point(x, y) + window = getTopWindowAt(x, y) + if not window: + error_messages.region() + return + hwnd = window.getHandle() + window_text = window.title if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() return @@ -146,22 +159,6 @@ def select_window(autosplit: AutoSplit): ) -def __get_window_from_point(x: int, y: int): - # Grab the window handle from the coordinates selected by the widget - hwnd = cast(int, win32gui.WindowFromPoint((x, y))) - - # Want to pull the parent window from the window handle - # By using GetAncestor we are able to get the parent window instead - # of the owner window. - # TODO: Fix stubs, IsChild should return a boolean - while win32gui.IsChild(win32gui.GetParent(hwnd), hwnd): - hwnd = cast(int, user32.GetAncestor(hwnd, GA_ROOT)) - - window_text = win32gui.GetWindowText(hwnd) - - return hwnd, window_text - - def align_region(autosplit: AutoSplit): # Check to see if a region has been set if not autosplit.capture_method.check_selected_region_exists(autosplit): diff --git a/src/user_profile.py b/src/user_profile.py index 3934afdd..eaba6d44 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -3,14 +3,13 @@ import os from typing import TYPE_CHECKING, TypedDict, cast -import keyboard import toml from PyQt6 import QtCore, QtWidgets import error_messages from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method from gen import design -from hotkeys import HOTKEYS, set_hotkey +from hotkeys import HOTKEYS, remove_all_hotkeys, set_hotkey from utils import auto_split_directory if TYPE_CHECKING: @@ -133,7 +132,7 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str autosplit.show_error_signal.emit(error_messages.invalid_settings) return False - keyboard.unhook_all() + remove_all_hotkeys() if not autosplit.is_auto_controlled: for hotkey, hotkey_name in [(hotkey, f"{hotkey}_hotkey") for hotkey in HOTKEYS]: if autosplit.settings_dict[hotkey_name]: diff --git a/src/utils.py b/src/utils.py index 2e986e3d..091d9f3b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -18,7 +18,8 @@ from gen.build_vars import AUTOSPLIT_BUILD_NUMBER, AUTOSPLIT_GITHUB_REPOSITORY if TYPE_CHECKING: - from typing_extensions import TypeGuard + from typing_extensions import ParamSpec, TypeGuard + P = ParamSpec("P") DWMWA_EXTENDED_FRAME_BOUNDS = 9 @@ -80,6 +81,19 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: return window_left_bounds, window_top_bounds, window_width, window_height +def open_file(file_path: str): + os.startfile(file_path) # nosec B606 + + +def get_or_create_eventloop(): + try: + return asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return asyncio.get_event_loop() + + def get_direct3d_device(): direct_3d_device = LearningModelDevice(LearningModelDeviceKind.DIRECT_X_HIGH_PERFORMANCE).direct3_d11_device if not direct_3d_device: @@ -97,6 +111,13 @@ async def coroutine(): return direct_3d_device +def try_get_direct3d_device(): + try: + return get_direct3d_device() + except OSError: + return None + + def fire_and_forget(func: Callable[..., Any]): """ Runs synchronous function asynchronously without waiting for a response @@ -110,19 +131,41 @@ def wrapped(*args: Any, **kwargs: Any): thread = Thread(target=func, args=args, kwargs=kwargs) thread.start() return thread - return asyncio.get_event_loop().run_in_executor(None, func, *args, *kwargs) + return get_or_create_eventloop().run_in_executor(None, func, *args, *kwargs) return wrapped +def getTopWindowAt(x: int, y: int): # noqa: N802 + # Immitating PyWinCTL's function + class Win32Window(): + def __init__(self, hwnd: int) -> None: + self._hWnd = hwnd + + def getHandle(self): # noqa: N802 + return self._hWnd + + @property + def title(self): + return win32gui.GetWindowText(self._hWnd) + hwnd = win32gui.WindowFromPoint((x, y)) + + # Want to pull the parent window from the window handle + # By using GetAncestor we are able to get the parent window instead of the owner window. + while win32gui.IsChild(win32gui.GetParent(hwnd), hwnd): + hwnd = ctypes.windll.user32.GetAncestor(hwnd, 2) + return Win32Window(hwnd) if hwnd else None + + # Environment specifics -WINDOWS_BUILD_NUMBER = int(version().split(".")[2]) +WINDOWS_BUILD_NUMBER = int(version().split(".")[2]) if sys.platform == "win32" else -1 FIRST_WIN_11_BUILD = 22000 """AutoSplit Version number""" FROZEN = hasattr(sys, "frozen") """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 AutoSplit.exe or AutoSplit.py""" +"""The directory of either the AutoSplit executable or AutoSplit.py""" +MAXBYTE = 255 # Shared strings # Set AUTOSPLIT_BUILD_NUMBER to an empty string to generate a clean version number From 8681d0d1d9f3b68bb22b4138147b25a0e03779c0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 14:41:27 -0500 Subject: [PATCH 04/15] Improved pyright and formatters usage --- .github/workflows/lint-and-build.yml | 7 ++++--- .vscode/extensions.json | 5 +++++ .vscode/settings.json | 12 ++++++++++++ build instructions.md | 3 ++- pyproject.toml | 14 +++++++------- scripts/build.ps1 | 2 +- scripts/install.ps1 | 5 ++++- scripts/lint.ps1 | 2 +- scripts/requirements-dev.txt | 6 +++++- src/capture_method/__init__.py | 1 + src/hotkeys.py | 7 +++---- src/menu_bar.py | 7 ++++++- src/user_profile.py | 8 ++++++-- 13 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 47c6c80a..126b6f4c 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -53,8 +53,6 @@ jobs: steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} uses: actions/checkout@v3 - - name: Set up Node - uses: actions/setup-node@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -64,7 +62,10 @@ jobs: - run: scripts/install.ps1 shell: pwsh - name: Analysing the code with Pyright - run: pyright src/ --warnings + uses: jakebailey/pyright-action@v1 + with: + working-directory: src/ + extra-args: --warnings Pylint: runs-on: windows-latest strategy: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d09fadc3..d2d596de 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,7 +4,10 @@ "bungcip.better-toml", "davidanson.vscode-markdownlint", "eamodio.gitlens", + "emeraldwalk.runonsave", + "ms-python.autopep8", "ms-python.flake8", + "ms-python.isort", "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", @@ -18,6 +21,8 @@ // Must disable in this workspace // // https://github.com/microsoft/vscode/issues/40239 // // + // We use autopep8 + "ms-python.black-formatter", // VSCode has implemented an optimized version "coenraads.bracket-pair-colorizer", "coenraads.bracket-pair-colorizer-2", diff --git a/.vscode/settings.json b/.vscode/settings.json index 99fc7ef8..a0762170 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,18 @@ "trailing-spaces.syntaxIgnore": [ "markdown" ], + "emeraldwalk.runonsave": { + "commands": [ + { + "match": "\\.py", + "cmd": "unify ${file} --in-place --quote=\"\\\"\"" + }, + { + "match": "\\.py", + "cmd": "add-trailing-comma ${file} --py36-plus" + }, + ] + }, "files.associations": { "*.json": "json", "extensions.json": "jsonc", diff --git a/build instructions.md b/build instructions.md index 05cd53ee..d40c8317 100644 --- a/build instructions.md +++ b/build instructions.md @@ -9,7 +9,8 @@ ### All platforms - [Python](https://www.python.org/downloads/) 3.9+. -- [Node](https://nodejs.org) is optional, but required for complete linting. Should be installed automatically. +- [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. - [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/pyproject.toml b/pyproject.toml index 66bd924d..37b350c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,12 +14,12 @@ ignore = [ [tool.pyright] typeCheckingMode = "strict" # Extra strict -reportImplicitStringConcatenation="error" -reportCallInDefaultInitializer="error" -reportMissingSuperCall="none" # False positives on base classes -reportPropertyTypeMismatch="error" -reportUninitializedInstanceVariable="error" -reportUnnecessaryTypeIgnoreComment="error" +reportImplicitStringConcatenation = "error" +reportCallInDefaultInitializer = "error" +reportMissingSuperCall = "none" # False positives on base classes +reportPropertyTypeMismatch = "error" +reportUninitializedInstanceVariable = "error" +reportUnnecessaryTypeIgnoreComment = "error" # Exclude from scanning when running pyright exclude = [ # Auto generated, produces unecessary `# type: ignore` @@ -30,7 +30,7 @@ ignore = [ # We expect stub files to be incomplete or contain useless statements "**/*.pyi", ] -reportUnusedCallResult="none" +reportUnusedCallResult = "none" # Type stubs may not be completable reportMissingTypeStubs = "warning" # False positives with TYPE_CHECKING diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 840db0c2..2435d2c2 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -7,4 +7,4 @@ $arguments = @( '--icon=res/icon.ico', '--splash=res/splash.png') -pyinstaller $arguments "$PSScriptRoot/../src/AutoSplit.py" +Start-Process pyinstaller -Wait -ArgumentList "$arguments `"$PSScriptRoot/../src/AutoSplit.py`"" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a4cfe071..669eeb4d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,7 +9,10 @@ If ($IsWindows) { $dev = If ($env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } # Ensures installation tools are up to date. This also aliases pip to pip3 on MacOS. python3 -m pip install wheel pip setuptools --upgrade -pip install -r "$PSScriptRoot/requirements$dev.txt" +pip install -r "$PSScriptRoot/requirements$dev.txt" --upgrade +if (Get-Command 'npm' -ErrorAction SilentlyContinue) { + npm i --global pyright@latest +} # Don't compile resources on the Build CI job as it'll do so in build script If ($dev) { diff --git a/scripts/lint.ps1 b/scripts/lint.ps1 index e9cd2a43..be1a0452 100644 --- a/scripts/lint.ps1 +++ b/scripts/lint.ps1 @@ -5,7 +5,7 @@ $exitCodes = 0 Write-Host "`nRunning autofixes..." isort src/ typings/ autopep8 $(git ls-files '**.py*') --in-place -unify src/ --recursive --in-place --quote='"""' +unify src/ --recursive --in-place --quote='"' add-trailing-comma $(git ls-files '**.py*') --py36-plus Write-Host "`nRunning Pyright..." diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index d8b5eee9..addd87b1 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -15,17 +15,21 @@ flake8-bugbear flake8-class-attributes-order flake8-comprehensions>=3.8 # flake8 5 support flake8-datetimez +flake8-isort>=4.2,<=5.0 # flake8 5 support ; Breaking issue (https://github.com/gforcada/flake8-isort/issues/128) flake8-pyi>=22.10.0 # Fixes for negative numbers flake8-quotes flake8-simplify isort pep8-naming pylint>=2.14,<3.0.0 # New checks # 3.0 still in pre-release -pyright>=1.1.276 # Typeshed update unify # # Run `./scripts/designer.ps1` to quickly open the bundled PyQt Designer. # Can also be downloaded externally as a non-python package qt6-applications # Types +types-d3dshot +types-keyboard +types-pyinstaller +types-pywin32 typing-extensions diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 71b129c2..2e5dbf9e 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -95,6 +95,7 @@ def get_method_by_index(self, index: int): if index <= 0: return first(self) return list(self.keys())[index] + if TYPE_CHECKING: # noqa: CCE002 __getitem__ = None # pyright: ignore[reportGeneralTypeIssues] # Disallow unsafe get diff --git a/src/hotkeys.py b/src/hotkeys.py index 095fa47b..93fee028 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -197,9 +197,9 @@ def __read_hotkey(): def __remove_key_already_set(autosplit: AutoSplit, key_name: str): for hotkey in HOTKEYS: settings_key = f"{hotkey}_hotkey" - if autosplit.settings_dict[settings_key] == key_name: + if autosplit.settings_dict[settings_key] == key_name: # pyright: ignore[reportGeneralTypeIssues] _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - autosplit.settings_dict[settings_key] = "" + autosplit.settings_dict[settings_key] = "" # pyright: ignore[reportGeneralTypeIssues] if autosplit.SettingsWidget: getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText("") @@ -280,8 +280,7 @@ def read_and_set_hotkey(): if autosplit.SettingsWidget: getattr(autosplit.SettingsWidget, f"{hotkey}_input").setText(hotkey_name) - autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name - autosplit.after_setting_hotkey_signal.emit() + autosplit.settings_dict[f"{hotkey}_hotkey"] = hotkey_name # pyright: ignore[reportGeneralTypeIssues] except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here error = exception autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(error)) diff --git a/src/menu_bar.py b/src/menu_bar.py index 4fc060bd..27467e4c 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -243,7 +243,12 @@ def hotkey_connect(hotkey: Hotkey): 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") - hotkey_input.setText(cast(str, autosplit.settings_dict[f"{hotkey}_hotkey"])) + hotkey_input.setText( + cast( + str, + autosplit.settings_dict[f"{hotkey}_hotkey"], # pyright: ignore[reportGeneralTypeIssues] + ), + ) set_hotkey_hotkey_button.clicked.connect(hotkey_connect(hotkey)) # Make it very clear that hotkeys are not used when auto-controlled diff --git a/src/user_profile.py b/src/user_profile.py index eaba6d44..746c37a0 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -135,8 +135,12 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str remove_all_hotkeys() if not autosplit.is_auto_controlled: for hotkey, hotkey_name in [(hotkey, f"{hotkey}_hotkey") for hotkey in HOTKEYS]: - if autosplit.settings_dict[hotkey_name]: - set_hotkey(autosplit, hotkey, cast(str, autosplit.settings_dict[hotkey_name])) + if autosplit.settings_dict[hotkey_name]: # pyright: ignore[reportGeneralTypeIssues] + set_hotkey( + autosplit, + hotkey, + cast(str, autosplit.settings_dict[hotkey_name]), # pyright: ignore[reportGeneralTypeIssues] + ) change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) if autosplit.settings_dict["capture_method"] != CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: From 60f8e60609f0e82ec541bf8d6d35bd9b58fa19d8 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 15:38:37 -0500 Subject: [PATCH 05/15] Config updates --- .flake8 | 8 +++----- .vscode/settings.json | 5 +++-- pyproject.toml | 2 +- scripts/requirements-dev.txt | 8 +++++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.flake8 b/.flake8 index 08a25d2f..52ff9c9b 100644 --- a/.flake8 +++ b/.flake8 @@ -11,11 +11,8 @@ ignore= SIM105, ; contextlib.suppress is roughly 3x slower than try/except CCE001, ; False positives for attribute docstrings per-file-ignores= - ; Docstrings in type stubs - ; Function bodys contain other than just ... (eg: raise) - ; Single quote docstrings - typings/cv2-stubs/__init__.pyi: Q000,E704,E501,N8,A001,A002,A003,CCE002,F401, Y021,Y010,Q002 ; Quotes + ; Allow ... on same line as class ; Allow ... on same line as def ; Line too long ; Naming conventions can't be controlled for external libraries @@ -24,7 +21,8 @@ per-file-ignores= ; Attribute names can't be controlled for external libraries ; False positive Class level expression with elipsis ; Type re-exports - *.pyi: Q000,E704,E501,N8,A001,A002,A003,CCE002,F401 + ; mypy 3.7 Union issue + *.pyi: Q000,E701,E704,E501,N8,A001,A002,A003,CCE002,F401,Y037 ; PyQt methods ignore-names=closeEvent,paintEvent,keyPressEvent,mousePressEvent,mouseMoveEvent,mouseReleaseEvent ; McCabe max-complexity is also taken care of by Pylint and doesn't fail the build there diff --git a/.vscode/settings.json b/.vscode/settings.json index a0762170..d6d1feeb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,11 +26,11 @@ "emeraldwalk.runonsave": { "commands": [ { - "match": "\\.py", + "match": "\\.pyi?", "cmd": "unify ${file} --in-place --quote=\"\\\"\"" }, { - "match": "\\.py", + "match": "\\.pyi?", "cmd": "add-trailing-comma ${file} --py36-plus" }, ] @@ -70,6 +70,7 @@ 120, // Our hard rule ], }, + "python.formatting.provider": "autopep8", "python.analysis.diagnosticMode": "workspace", "python.linting.enabled": true, // Use the new Pylint extension instead diff --git a/pyproject.toml b/pyproject.toml index 37b350c9..d7fe7563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ # https://github.com/hhatto/autopep8#more-advanced-usage [tool.autopep8] max_line_length = 120 -# recursive = true +recursive = true aggressive = 3 ignore = [ "E124", # Closing bracket may not match multi-line method invocation style (enforced by add-trailing-comma) diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index addd87b1..28bd6fae 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -6,8 +6,7 @@ # Dependencies -r requirements.txt # -# Linters and formatters -add-trailing-comma>=2.3.0 # Added support for with statement +# Linters bandit flake8>=5 # flake8-pyi deprecation warnings flake8-builtins @@ -19,9 +18,12 @@ flake8-isort>=4.2,<=5.0 # flake8 5 support ; Breaking issue (https://github.com flake8-pyi>=22.10.0 # Fixes for negative numbers flake8-quotes flake8-simplify -isort pep8-naming pylint>=2.14,<3.0.0 # New checks # 3.0 still in pre-release +# Formatters +add-trailing-comma>=2.3.0 # Added support for with statement +autopep8>=2.0.0 # New checks +isort unify # # Run `./scripts/designer.ps1` to quickly open the bundled PyQt Designer. From a5fda9b627795c65ac06a61a9c26298e76d353c7 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 16:21:10 -0500 Subject: [PATCH 06/15] Fix links --- .flake8 | 2 +- res/about.ui | 2 +- res/design.ui | 24 ++++++++++++------------ src/AutoSplit.py | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.flake8 b/.flake8 index 52ff9c9b..01e8f922 100644 --- a/.flake8 +++ b/.flake8 @@ -22,7 +22,7 @@ per-file-ignores= ; False positive Class level expression with elipsis ; Type re-exports ; mypy 3.7 Union issue - *.pyi: Q000,E701,E704,E501,N8,A001,A002,A003,CCE002,F401,Y037 + *.pyi: Q000,E701,E704,E501,N8,A001,A002,A003,CCE002,F401,Y037 ; PyQt methods ignore-names=closeEvent,paintEvent,keyPressEvent,mousePressEvent,mouseMoveEvent,mouseReleaseEvent ; McCabe max-complexity is also taken care of by Pylint and doesn't fail the build there diff --git a/res/about.ui b/res/about.ui index 481ae936..5aa56a75 100644 --- a/res/about.ui +++ b/res/about.ui @@ -103,7 +103,7 @@ Thank you! - + <html><head/><body><p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=BYRHQG69YRHBA&amp;item_name=AutoSplit+development&amp;currency_code=USD&amp;source=url"><img src=":/resources/btn_donateCC_LG.png"/></a></p></body></html> :/resources/btn_donateCC_LG.png diff --git a/res/design.ui b/res/design.ui index 0e80cd89..7d67e0e0 100644 --- a/res/design.ui +++ b/res/design.ui @@ -40,10 +40,10 @@ - 30 + 11 143 - 7 - 16 + 44 + 20 @@ -240,10 +240,10 @@ - 17 + 11 183 - 33 - 16 + 44 + 20 @@ -256,10 +256,10 @@ - 70 + 66 183 - 41 - 16 + 44 + 20 @@ -427,10 +427,10 @@ - 85 + 66 143 - 7 - 16 + 44 + 20 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index ca9ac92e..9aec3bba 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -144,8 +144,8 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s # Connecting menu actions self.action_view_help.triggered.connect(view_help) self.action_about.triggered.connect(lambda: open_about(self)) - self.action_about_qt.triggered.connect(lambda: about_qt) - self.action_about_qt_for_python.triggered.connect(lambda: about_qt_for_python) + self.action_about_qt.triggered.connect(about_qt) + self.action_about_qt_for_python.triggered.connect(about_qt_for_python) self.action_check_for_updates.triggered.connect(lambda: check_for_updates(self)) self.action_settings.triggered.connect(lambda: open_settings(self)) self.action_save_profile.triggered.connect(lambda: user_profile.save_settings(self)) From 3db690d7fdfcf500a5396ecdf1e916de12c5827d Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 16:57:59 -0500 Subject: [PATCH 07/15] Disable next/previous while delaying --- src/AutoSplit.py | 4 +++- src/region_selection.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 9aec3bba..6c887498 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -572,6 +572,8 @@ def __auto_splitter(self): if split_delay > 0 and not self.waiting_for_split_delay: split_time = round(time() + split_delay * 1000) self.waiting_for_split_delay = True + self.next_image_button.setEnabled(False) + self.previous_image_button.setEnabled(False) self.undo_split_button.setEnabled(False) self.skip_split_button.setEnabled(False) self.current_image_file_label.clear() @@ -679,7 +681,7 @@ def __pause_loop(self, stop_time: float, message: str): return False start_time = time() # Set a "pause" split image number. - # This is done so that it can detect if user hit split/undo split while paused. + # This is done so that it can detect if user hit split/undo split while paused/delayed. pause_split_image_number = self.split_image_number while True: # Calculate similarity for reset image diff --git a/src/region_selection.py b/src/region_selection.py index 7ae32407..998ba92a 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -105,8 +105,8 @@ def select_region(autosplit: AutoSplit): 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 + offset_x = window_x + left_bounds + offset_y = window_y + top_bounds __set_region_values( autosplit, left=x - offset_x, From db8483f82f1136eb225526e46d76464009ecdf36 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 18:05:08 -0500 Subject: [PATCH 08/15] proper `is_running` variable --- src/AutoSplit.py | 24 ++++++++++++++---------- src/hotkeys.py | 4 ++-- src/utils.py | 1 - 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 6c887498..b02a7089 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -31,8 +31,8 @@ from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images from user_profile import DEFAULT_PROFILE from utils import ( - AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, FROZEN, START_AUTO_SPLITTER_TEXT, WINDOWS_BUILD_NUMBER, auto_split_directory, - decimal, is_valid_image, open_file, + AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, FROZEN, WINDOWS_BUILD_NUMBER, auto_split_directory, decimal, is_valid_image, + open_file, ) CHECK_FPS_ITERATIONS = 10 @@ -78,6 +78,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # pylint: disable=too-many- split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] split_groups: list[list[int]] = [] capture_method = CaptureMethodBase() + is_running = False # Last loaded settings empty and last successful loaded settings file path to None until we try to load them last_loaded_settings = DEFAULT_PROFILE @@ -426,7 +427,7 @@ def undo_split(self, navigate_image_only: bool = False): """ # Can't undo until timer is started # or Undoing past the first image - if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT \ + if not self.is_running \ or "Delayed Split" in self.current_split_image.text() \ or (not self.undo_split_button.isEnabled() and not self.is_auto_controlled) \ or self.__is_current_split_out_of_range(): @@ -450,7 +451,7 @@ def skip_split(self, navigate_image_only: bool = False): """ # Can't skip or split until timer is started # or Splitting/skipping when there are no images left - if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT \ + 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 self.__is_current_split_out_of_range(): @@ -473,14 +474,16 @@ def pause(self): pass def reset(self): - # When the reset button or hotkey is pressed, it will change this text, - # which will trigger in the __auto_splitter function, if running, to abort and change GUI. - self.start_auto_splitter_button.setText(START_AUTO_SPLITTER_TEXT) + """" + 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.is_running = False # 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 self.start_auto_splitter_button.text() == "Running..." \ + if self.is_running \ or (not self.start_auto_splitter_button.isEnabled() and not self.is_auto_controlled): return @@ -494,7 +497,7 @@ def __check_for_reset_state_update_ui(self): """ Check if AutoSplit is started, if not either restart (loop splits) or update the GUI """ - if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT: + if not self.is_running: if self.settings_dict["loop_splits"]: self.start_auto_splitter_signal.emit() else: @@ -533,6 +536,7 @@ def __auto_splitter(self): current_group = [] self.split_groups.append(current_group) + self.is_running = True self.gui_changes_on_start() # Initialize a few attributes @@ -725,7 +729,7 @@ def gui_changes_on_start(self): QApplication.processEvents() def gui_changes_on_reset(self, safe_to_reload_start_image: bool = False): - self.start_auto_splitter_button.setText(START_AUTO_SPLITTER_TEXT) + self.start_auto_splitter_button.setText("Start Auto Splitter") self.image_loop_value_label.setText("N/A") self.current_split_image.clear() self.current_image_file_label.clear() diff --git a/src/hotkeys.py b/src/hotkeys.py index 93fee028..64f7f5f4 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -8,7 +8,7 @@ from PyQt6 import QtWidgets import error_messages -from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit +from utils import fire_and_forget, is_digit if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -43,7 +43,7 @@ def after_setting_hotkey(autosplit: AutoSplit): Do all of these things after you set a hotkey. A signal connects to this because changing GUI stuff is only possible in the main thread """ - if autosplit.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT: + if not autosplit.is_running: autosplit.start_auto_splitter_button.setEnabled(True) if autosplit.SettingsWidget: for hotkey in HOTKEYS: diff --git a/src/utils.py b/src/utils.py index 091d9f3b..0b1da8f4 100644 --- a/src/utils.py +++ b/src/utils.py @@ -171,5 +171,4 @@ def title(self): # Set AUTOSPLIT_BUILD_NUMBER to an empty string to generate a clean version number # AUTOSPLIT_BUILD_NUMBER = "" # pyright: ignore[reportConstantRedefinition] # noqa: F811 AUTOSPLIT_VERSION = "2.0.0-alpha.6" + (f"-{AUTOSPLIT_BUILD_NUMBER}" if AUTOSPLIT_BUILD_NUMBER else "") -START_AUTO_SPLITTER_TEXT = "Start Auto Splitter" GITHUB_REPOSITORY = AUTOSPLIT_GITHUB_REPOSITORY From 9ef461cc1602cfdcc4c88e07e37b77947e7b8ce6 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 18:09:33 -0500 Subject: [PATCH 09/15] Fix loading and clearing of start image --- src/AutoSplit.py | 1 + src/user_profile.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index b02a7089..5b257038 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -257,6 +257,7 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo self.timer_start_image.stop() self.current_image_file_label.setText("-") self.start_image_status_value_label.setText("not found") + set_preview_image(self.current_split_image, None, True) if not (validate_before_parsing(self, started_by_button) and parse_and_validate_images(self)): QApplication.processEvents() diff --git a/src/user_profile.py b/src/user_profile.py index 746c37a0..8c6faaad 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -166,7 +166,9 @@ def load_settings(autosplit: AutoSplit, from_path: str = ""): return autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path - autosplit.load_start_image_signal.emit() + # TODO: Should this check be in `__load_start_image` ? + if not autosplit.is_running: + autosplit.load_start_image_signal.emit() def load_settings_on_open(autosplit: AutoSplit): From 2a935fae22ce3cd4aa170423afc3c4211a1ba76a Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 14 Nov 2022 18:59:28 -0500 Subject: [PATCH 10/15] Rename "already_running" to "already_open" --- src/AutoSplit.py | 8 +++++--- src/error_messages.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 5b257038..a4fc2079 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -699,6 +699,8 @@ def __pause_loop(self, stop_time: float, message: str): time_delta >= stop_time # Check for skip split / next image: or self.split_image_number > pause_split_image_number + # Check for undo split / previous image: + or self.split_image_number < pause_split_image_number ): break @@ -919,7 +921,7 @@ def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" -def is_already_running(): +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 max_processes = 3 if FROZEN else 1 @@ -938,8 +940,8 @@ def main(): try: app.setWindowIcon(QtGui.QIcon(":/resources/icon.ico")) - if is_already_running(): - error_messages.already_running() + if is_already_open(): + error_messages.already_open() AutoSplit() diff --git a/src/error_messages.py b/src/error_messages.py index 81e2cbfb..2658a083 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -142,7 +142,7 @@ def stdin_lost(): set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") -def already_running(): +def already_open(): set_text_message( "An instance of AutoSplit is already running.
Are you sure you want to open a another one?", "", From 035e0bf28e7f0dac8682532837fb015ea7911099 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Nov 2022 03:34:09 -0500 Subject: [PATCH 11/15] Fix delay intervals --- src/AutoSplit.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index a4fc2079..baf9cb9f 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -337,16 +337,18 @@ def __start_image_function(self): self.start_image_status_value_label.setText("delaying start...") delay_start_time = time() start_delay = self.start_image.get_delay_time(self) / 1000 - while time() - delay_start_time < start_delay: - delay_time_left = start_delay - (time() - delay_start_time) + time_delta = 0 + while time_delta < start_delay: + delay_time_left = start_delay - time_delta self.current_split_image.setText( f"Delayed Before Starting:\n {seconds_remaining_text(delay_time_left)}", ) - QTest.qWait(1) + # Wait 0.1s. Doesn't need to be shorter as we only show 1 decimal + QTest.qWait(100) + time_delta = time() - delay_start_time self.start_image_status_value_label.setText("started") send_command(self, "start") - QTest.qWait(int(1 / self.settings_dict["fps_limit"])) self.start_auto_splitter() # update x, y, width, height when spinbox values are changed @@ -649,9 +651,9 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ QApplication.processEvents() # Limit the number of time the comparison runs to reduce cpu usage + frame_interval = 1 / self.settings_dict["fps_limit"] # Use a time delta to have a consistant check interval - frame_interval: float = 1 / self.settings_dict["fps_limit"] - wait_delta = int(frame_interval - (time() - start) % frame_interval) + wait_delta_ms = int((frame_interval - (time() - start) % frame_interval) * 1000) below_flag = self.split_image.check_flag(BELOW_FLAG) # if the b flag is set, let similarity go above threshold first, @@ -664,7 +666,7 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ break if not self.split_below_threshold: self.split_below_threshold = True - QTest.qWait(wait_delta) + QTest.qWait(wait_delta_ms) continue elif ( # pylint: disable=confusing-consecutive-elif @@ -673,7 +675,7 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_ self.split_below_threshold = False break - QTest.qWait(wait_delta) + QTest.qWait(wait_delta_ms) def __pause_loop(self, stop_time: float, message: str): """ From 579bb72bc968b2cebb1645cc551da4f7ac6e104d Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Nov 2022 03:34:45 -0500 Subject: [PATCH 12/15] Add support for start image pause time --- src/AutoSplit.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index baf9cb9f..aada59fc 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -281,8 +281,7 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo self.split_image_number = 0 - start_pause_time = self.start_image.get_pause_time(self) - if not wait_for_delay and start_pause_time > 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("-") self.table_current_image_threshold_label.setText("-") @@ -542,6 +541,10 @@ def __auto_splitter(self): self.is_running = True self.gui_changes_on_start() + # Start pause time + if self.start_image: + self.__pause_loop(self.start_image.get_pause_time(self), "None (Paused).") + # Initialize a few attributes self.split_image_number = 0 self.waiting_for_split_delay = False @@ -604,8 +607,7 @@ def __auto_splitter(self): # 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 - pause_time = self.split_image.get_pause_time(self) - if self.__pause_loop(pause_time, "None (Paused)."): + if self.__pause_loop(self.split_image.get_pause_time(self), "None (Paused)."): return # loop breaks to here when the last image splits @@ -794,7 +796,10 @@ def __reset_if_should(self, capture: cv2.Mat | None): similarity = self.reset_image.compare_with_capture(self, capture) threshold = self.reset_image.get_similarity_threshold(self) - paused = time() - self.run_start_time <= self.reset_image.get_pause_time(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") From 06413abecc7eff9b423e038de9a684639118aec2 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Nov 2022 03:53:00 -0500 Subject: [PATCH 13/15] Default to MediaCapture's d3d --- src/utils.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/utils.py b/src/utils.py index 0b1da8f4..50ccc836 100644 --- a/src/utils.py +++ b/src/utils.py @@ -95,17 +95,22 @@ def get_or_create_eventloop(): def get_direct3d_device(): - direct_3d_device = LearningModelDevice(LearningModelDeviceKind.DIRECT_X_HIGH_PERFORMANCE).direct3_d11_device + # 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() or asyncio.sleep(0)) + 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: - # 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 coroutine(): - await (media_capture.initialize_async() or asyncio.sleep(0)) - asyncio.run(coroutine()) - direct_3d_device = media_capture.media_capture_settings and \ - media_capture.media_capture_settings.direct3_d11_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 + # TODO: Unknown potential error, I don't have an older Win10 machine to test. + except BaseException: # pylint: disable=broad-except + pass if not direct_3d_device: raise OSError("Unable to initialize a Direct3D Device.") return direct_3d_device From 1099cfe32f8b1aa1fcee74f4ebbb483cb5add9e5 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Nov 2022 04:14:51 -0500 Subject: [PATCH 14/15] Re-enable buttons after delay --- src/AutoSplit.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index aada59fc..86bab32b 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -582,16 +582,23 @@ def __auto_splitter(self): if split_delay > 0 and not self.waiting_for_split_delay: split_time = round(time() + split_delay * 1000) self.waiting_for_split_delay = True - self.next_image_button.setEnabled(False) - self.previous_image_button.setEnabled(False) - self.undo_split_button.setEnabled(False) - self.skip_split_button.setEnabled(False) + buttons_to_disable = [ + self.next_image_button, + self.previous_image_button, + self.undo_split_button, + self.skip_split_button, + ] + for button in buttons_to_disable: + button.setEnabled(False) self.current_image_file_label.clear() # check for reset while delayed and display a counter of the remaining split delay time if self.__pause_loop(split_delay, "Delayed Split:"): return + for button in buttons_to_disable: + button.setEnabled(True) + self.waiting_for_split_delay = False # if {p} flag hit pause key, otherwise hit split hotkey From 4018d55b05e9162a16613f4722d58e5861aa7c38 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 19 Nov 2022 05:42:49 -0500 Subject: [PATCH 15/15] Beta 1 (feature-freeze, bugfixes only) --- src/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.py b/src/utils.py index 50ccc836..e2596682 100644 --- a/src/utils.py +++ b/src/utils.py @@ -175,5 +175,5 @@ def title(self): # Shared strings # Set AUTOSPLIT_BUILD_NUMBER to an empty string to generate a clean version number # AUTOSPLIT_BUILD_NUMBER = "" # pyright: ignore[reportConstantRedefinition] # noqa: F811 -AUTOSPLIT_VERSION = "2.0.0-alpha.6" + (f"-{AUTOSPLIT_BUILD_NUMBER}" if AUTOSPLIT_BUILD_NUMBER else "") +AUTOSPLIT_VERSION = "2.0.0-beta.1" + (f"-{AUTOSPLIT_BUILD_NUMBER}" if AUTOSPLIT_BUILD_NUMBER else "") GITHUB_REPOSITORY = AUTOSPLIT_GITHUB_REPOSITORY