From 1e261f2e5a84314b5f04f896b46a1162e5021d46 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 3 Jul 2022 17:47:44 -0400 Subject: [PATCH 01/28] temporarily disable auto reset --- res/design.ui | 31 +++++++++++++++++++++++++++++++ src/AutoSplit.py | 4 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/res/design.ui b/res/design.ui index b4f07175..0debb0ab 100644 --- a/res/design.ui +++ b/res/design.ui @@ -894,6 +894,35 @@ > + + + + 10 + 360 + 91 + 31 + + + + + + + + + + 30 + 360 + 71 + 31 + + + + Disable auto reset image + + + true + + x_label select_region_button start_auto_splitter_button @@ -930,6 +959,8 @@ image_loop_value_label previous_image_button next_image_button + disable_auto_reset_label + disable_auto_reset_checkbox diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 01198571..e52fb3ad 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -783,7 +783,9 @@ def __reset_if_should(self, capture: Optional[cv2.Mat]): """ Checks if we should reset, resets if it's the case, and returns the result """ - if self.reset_image: + if self.disable_auto_reset_checkbox.isChecked(): + self.table_reset_image_live_label.setText("disabled") + elif self.reset_image: similarity = self.reset_image.compare_with_capture(self, capture) threshold = self.reset_image.get_similarity_threshold(self) From 10c74d9a77fa0cbad307954a7584524d6832c62c Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 3 Jul 2022 18:19:19 -0400 Subject: [PATCH 02/28] . --- res/design.ui | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/res/design.ui b/res/design.ui index 0debb0ab..18faeb78 100644 --- a/res/design.ui +++ b/res/design.ui @@ -904,23 +904,8 @@ - - - - - - - 30 - 360 - 71 - 31 - - - - Disable auto reset image - - - true + Disable auto +reset image x_label @@ -959,7 +944,6 @@ image_loop_value_label previous_image_button next_image_button - disable_auto_reset_label disable_auto_reset_checkbox From c0068268005512422c75725e3285922fa57111ff Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 12 Jul 2022 16:09:53 -0400 Subject: [PATCH 03/28] Updated Scripts to OS agnostic powershell --- .github/workflows/lint-and-build.yml | 10 +++++----- .vscode/settings.json | 7 +++++++ .vscode/tasks.json | 2 +- README.md | 8 ++++---- scripts/build.bat | 8 -------- scripts/build.ps1 | 15 +++++++++++++++ scripts/compile_resources.bat | 9 --------- scripts/compile_resources.ps1 | 12 ++++++++++++ scripts/designer.bat | 0 scripts/install.bat | 5 ----- scripts/install.ps1 | 9 +++++++++ scripts/lint.ps1 | 20 ++++++++++++-------- scripts/requirements.txt | 13 +++++++------ scripts/start.bat | 2 -- scripts/start.ps1 | 3 +++ 15 files changed, 75 insertions(+), 48 deletions(-) delete mode 100644 scripts/build.bat create mode 100755 scripts/build.ps1 delete mode 100644 scripts/compile_resources.bat create mode 100755 scripts/compile_resources.ps1 mode change 100644 => 100755 scripts/designer.bat delete mode 100644 scripts/install.bat create mode 100755 scripts/install.ps1 mode change 100644 => 100755 scripts/lint.ps1 delete mode 100644 scripts/start.bat create mode 100755 scripts/start.ps1 diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 6630a9c7..8f4f48f7 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -43,7 +43,7 @@ jobs: pip install -r "scripts/requirements-dev.txt" npm install -g pyright npm list -g pyright - - run: scripts/compile_resources.bat + - run: scripts/compile_resources.ps1 - name: Analysing the code with Pyright run: pyright --warnings Pylint: @@ -63,7 +63,7 @@ jobs: cache-dependency-path: 'scripts/requirements-dev.txt' - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.bat + - run: scripts/compile_resources.ps1 - name: Analysing the code with Pylint run: pylint --reports=y --output-format=colorized src/ Flake8: @@ -83,7 +83,7 @@ jobs: cache-dependency-path: 'scripts/requirements-dev.txt' - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.bat + - run: scripts/compile_resources.ps1 - name: Analysing the code with Flake8 run: flake8 Bandit: @@ -103,7 +103,7 @@ jobs: cache-dependency-path: 'scripts/requirements-dev.txt' - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.bat + - run: scripts/compile_resources.ps1 - name: Analysing the code with Bandit run: bandit -n 1 --severity-level medium --recursive src Build: @@ -122,7 +122,7 @@ jobs: cache: 'pip' - name: Install dependencies run: pip install -r "scripts/requirements.txt" - - run: scripts/build.bat + - run: scripts/build.ps1 - name: Upload Build Artifact uses: actions/upload-artifact@v3 with: diff --git a/.vscode/settings.json b/.vscode/settings.json index a4a19fd0..c249f12c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -77,4 +77,11 @@ // Just another wrapper, use Flake8 OR this "python.linting.pylamaEnabled": false, "python.linting.banditEnabled": true, + "powershell.codeFormatting.pipelineIndentationStyle": "IncreaseIndentationForFirstPipeline", + "powershell.codeFormatting.autoCorrectAliases": true, + "powershell.codeFormatting.trimWhitespaceAroundPipe": true, + "powershell.codeFormatting.useConstantStrings": true, + "powershell.codeFormatting.useCorrectCasing": true, + "powershell.codeFormatting.whitespaceBetweenParameters": true, + "powershell.integratedConsole.showOnStartup": false, } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4ac48ed7..26a9e03d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Build AutoSplit", "type": "shell", - "command": "scripts/build.bat", + "command": "scripts/build.ps1", "group": { "kind": "build", "isDefault": true diff --git a/README.md b/README.md index 9a79ffcf..618fc23d 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ This program can be used to automatically start, split, and reset your preferred - 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/). - Node is optional, but required for complete linting (using Pyright). - Read [requirements.txt](/scripts/requirements.txt) for more information on how to install, run and build the python code. - - Run `.\scripts\install.bat` to install all dependencies. - - Run the app directly with `.\scripts\start.bat [--auto-controlled]`. - - Run `.\scripts\build.bat` to build an executable. -- Recompile resources after modifications by running `.\scripts\compile_resources.bat`. + - 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. ## OPTIONS diff --git a/scripts/build.bat b/scripts/build.bat deleted file mode 100644 index b9469fff..00000000 --- a/scripts/build.bat +++ /dev/null @@ -1,8 +0,0 @@ -CALL "%~p0compile_resources.bat" -pyinstaller ^ - --windowed ^ - --onefile ^ - --additional-hooks-dir=Pyinstaller\hooks ^ - --icon=res\icon.ico ^ - --splash=res\splash.png ^ - "%~p0..\src\AutoSplit.py" diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100755 index 00000000..59b22626 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,15 @@ +& "$PSScriptRoot/compile_resources.ps1" +pyinstaller ` + --windowed ` + --onefile ` + --additional-hooks-dir=Pyinstaller/hooks ` + --icon=res/icon.ico ` + --splash=res/splash.png ` + "$PSScriptRoot/../src/AutoSplit.py" + +If ($IsLinux) { + Move-Item $PSScriptRoot/../dist/AutoSplit $PSScriptRoot/../dist/AutoSplit.elf + If ($LastExitCode -eq 0) { + Write-Host 'Added .elf extension' + } +} diff --git a/scripts/compile_resources.bat b/scripts/compile_resources.bat deleted file mode 100644 index ba578623..00000000 --- a/scripts/compile_resources.bat +++ /dev/null @@ -1,9 +0,0 @@ -cd "%~dp0.." -@ if not exist .\src\gen ( - md .\src\gen -) -pyuic6 ".\res\about.ui" -o ".\src\gen\about.py" -pyuic6 ".\res\design.ui" -o ".\src\gen\design.py" -pyuic6 ".\res\settings.ui" -o ".\src\gen\settings.py" -pyuic6 ".\res\update_checker.ui" -o ".\src\gen\update_checker.py" -pyside6-rcc ".\res\resources.qrc" -o ".\src\gen\resources_rc.py" diff --git a/scripts/compile_resources.ps1 b/scripts/compile_resources.ps1 new file mode 100755 index 00000000..6495dd33 --- /dev/null +++ b/scripts/compile_resources.ps1 @@ -0,0 +1,12 @@ +$originalDirectory = $pwd +Set-Location "$PSScriptRoot/.." + +New-Item -Force -ItemType directory ./src/gen | Out-Null +pyuic6 './res/about.ui' -o './src/gen/about.py' +pyuic6 './res/design.ui' -o './src/gen/design.py' +pyuic6 './res/settings.ui' -o './src/gen/settings.py' +pyuic6 './res/update_checker.ui' -o './src/gen/update_checker.py' +pyside6-rcc './res/resources.qrc' -o './src/gen/resources_rc.py' +Write-Host 'Generated code from .ui files' + +Set-Location $originalDirectory diff --git a/scripts/designer.bat b/scripts/designer.bat old mode 100644 new mode 100755 diff --git a/scripts/install.bat b/scripts/install.bat deleted file mode 100644 index 8f7f45e4..00000000 --- a/scripts/install.bat +++ /dev/null @@ -1,5 +0,0 @@ -py -m pip install wheel --upgrade -py -m pip install -r "%~p0requirements-dev.txt" -CALL "%~p0compile_resources.bat" -CALL npm install -g pyright@latest -CALL npm list -g pyright diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100755 index 00000000..e741c3d5 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,9 @@ +If ($IsLinux) { + sudo apt-get install python3-tk +} + +python -m pip install wheel --upgrade +python -m pip install -r "$PSScriptRoot/requirements-dev.txt" +& "$PSScriptRoot/compile_resources.ps1" +npm install -g pyright@latest +npm list -g pyright diff --git a/scripts/lint.ps1 b/scripts/lint.ps1 old mode 100644 new mode 100755 index e11bbd86..29ef583c --- a/scripts/lint.ps1 +++ b/scripts/lint.ps1 @@ -1,6 +1,5 @@ $originalDirectory = $pwd -cd "$PSScriptRoot\.." -Write-Host $Script:MyInvocation.MyCommand.Path +Set-Location "$PSScriptRoot/.." $exitCodes = 0 Write-Host "`nRunning Pyright..." @@ -8,7 +7,8 @@ pyright --warnings $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Pyright failed ($LastExitCode)" -ForegroundColor Red -} else { +} +else { Write-Host "`Pyright passed" -ForegroundColor Green } @@ -17,7 +17,8 @@ pylint --output-format=colorized src/ $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Pylint failed ($LastExitCode)" -ForegroundColor Red -} else { +} +else { Write-Host "`Pylint passed" -ForegroundColor Green } @@ -26,7 +27,8 @@ flake8 $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Flake8 failed ($LastExitCode)" -ForegroundColor Red -} else { +} +else { Write-Host "`Flake8 passed" -ForegroundColor Green } @@ -35,15 +37,17 @@ bandit -f custom --silent --recursive src # $exitCodes += $LastExitCode # Returns 1 on low if ($LastExitCode -gt 0) { Write-Host "`Bandit warning ($LastExitCode)" -ForegroundColor Yellow -} else { +} +else { Write-Host "`Bandit passed" -ForegroundColor Green } if ($exitCodes -gt 0) { Write-Host "`nLinting failed ($exitCodes)" -ForegroundColor Red -} else { +} +else { Write-Host "`nLinting passed" -ForegroundColor Green } -cd $originalDirectory +Set-Location $originalDirectory diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 263c7a8d..154105c9 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -2,12 +2,12 @@ # # Python: CPython 3.9+ # -# Usage: .\scripts\install.bat +# Usage: ./scripts/install.ps1 # # If you're having issues with the libraries, you might want to first run: -# pip uninstall -y -r .\scripts\requirements-dev.txt +# pip uninstall -y -r ./scripts/requirements-dev.txt # -# Creating AutoSplit.exe with PyInstaller: .\scripts\build.bat +# Creating AutoSplit.exe with PyInstaller: ./scripts/build.ps1 # # Dependencies: numpy>=1.22.0rc1,<1.23 # Type issue @@ -19,14 +19,15 @@ keyboard packaging Pillow pyautogui -pywin32>=301 requests certifi toml -winsdk>=v1.0.0b4 pygrabber -git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot # https://github.com/SerpentAI/D3DShot/issues/44 typing-extensions +# Windows-only +pywin32>=301 +winsdk>=v1.0.0b4 +git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot # https://github.com/SerpentAI/D3DShot/issues/44 # # Build PyInstaller diff --git a/scripts/start.bat b/scripts/start.bat deleted file mode 100644 index cd894a28..00000000 --- a/scripts/start.bat +++ /dev/null @@ -1,2 +0,0 @@ -CALL "%~p0compile_resources.bat" -py -3.9 "%~p0..\src\AutoSplit.py" %* diff --git a/scripts/start.ps1 b/scripts/start.ps1 new file mode 100755 index 00000000..70d6fd8b --- /dev/null +++ b/scripts/start.ps1 @@ -0,0 +1,3 @@ +param ([string]$p1) +& "$PSScriptRoot/compile_resources.ps1" +python "$PSScriptRoot/../src/AutoSplit.py" $p1 From e55547915da488fc87340978d04a3ae8f8cfe415 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 12 Jul 2022 23:25:01 -0400 Subject: [PATCH 04/28] Abstracted all capture methods to its own module --- pyproject.toml | 2 + src/AutoSplit.py | 68 +++--- src/WindowsGraphicsCapture.py | 61 ----- src/capture_method/BitBltCaptureMethod.py | 84 +++++++ .../DesktopDuplicationCaptureMethod.py | 42 ++++ .../ForceFullContentRenderingCaptureMethod.py | 5 + .../VideoCaptureDeviceCaptureMethod.py | 41 ++++ .../WindowsGraphicsCaptureMethod.py | 139 +++++++++++ .../__init__.py} | 75 ++++-- src/capture_method/interface.py | 35 +++ src/menu_bar.py | 57 ++--- src/region_capture.py | 225 ------------------ src/region_selection.py | 34 +-- src/user_profile.py | 36 +-- src/utils.py | 23 +- typings/cv2-stubs/__init__.pyi | 6 +- 16 files changed, 500 insertions(+), 433 deletions(-) delete mode 100644 src/WindowsGraphicsCapture.py create mode 100644 src/capture_method/BitBltCaptureMethod.py create mode 100644 src/capture_method/DesktopDuplicationCaptureMethod.py create mode 100644 src/capture_method/ForceFullContentRenderingCaptureMethod.py create mode 100644 src/capture_method/VideoCaptureDeviceCaptureMethod.py create mode 100644 src/capture_method/WindowsGraphicsCaptureMethod.py rename src/{CaptureMethod.py => capture_method/__init__.py} (71%) create mode 100644 src/capture_method/interface.py delete mode 100644 src/region_capture.py diff --git a/pyproject.toml b/pyproject.toml index e35ac2b2..48643933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,8 @@ disable = [ "unused-import", # Similar lines in 2 files, doesn't really work "R0801", + # Doesn't work with local imports + "import-error", # False positives with PyQt .connect "no-value-for-parameter", # Not as long as we support Python 3.9 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 01198571..339292cd 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -15,27 +15,22 @@ import cv2 from PyQt6 import QtCore, QtGui from PyQt6.QtTest import QTest -from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox, QWidget -from win32 import win32gui -from winsdk.windows.graphics.capture.interop import create_for_window +from PyQt6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget import error_messages import user_profile from AutoControlledWorker import AutoControlledWorker from AutoSplitImage import COMPARISON_RESIZE, START_KEYWORD, AutoSplitImage, ImageType -from CaptureMethod import CaptureMethod +from capture_method import CaptureMethodEnum, CaptureMethodInterface from gen import about, design, settings, update_checker from hotkeys import HOTKEYS, after_setting_hotkey, send_command from menu_bar import (check_for_updates, get_default_settings_from_ui, open_about, open_settings, open_update_checker, view_help) -from region_capture import capture_region, set_ui_image -from region_selection import (align_region, create_windows_graphics_capture, select_region, select_window, - validate_before_parsing) +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 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) -from WindowsGraphicsCapture import WindowsGraphicsCapture CHECK_FPS_ITERATIONS = 10 @@ -83,12 +78,12 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # Initialize a few attributes hwnd = 0 """Window Handle used for Capture Region""" - windows_graphics_capture: Optional[WindowsGraphicsCapture] = None last_saved_settings = DEFAULT_PROFILE similarity = 0.0 split_image_number = 0 split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] split_groups: list[list[int]] = [] + capture_method = CaptureMethodInterface() # Last loaded settings empty and last successful loaded settings file path to None until we try to load them last_loaded_settings = DEFAULT_PROFILE @@ -108,7 +103,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): reset_image: Optional[AutoSplitImage] = None split_images: list[AutoSplitImage] = [] split_image: Optional[AutoSplitImage] = None - capture_device: Optional[cv2.VideoCapture] = None update_auto_control: Optional[QtCore.QThread] = None def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-many-statements @@ -243,15 +237,15 @@ def __browse(self): def __live_image_function(self): capture_region_window_label = self.settings_dict["capture_device_name"] \ - if self.settings_dict["capture_method"] == CaptureMethod.VIDEO_CAPTURE_DEVICE \ + if self.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE \ else self.settings_dict["captured_window_title"] self.capture_region_window_label.setText(capture_region_window_label) if not (self.settings_dict["live_capture_region"] and capture_region_window_label): self.live_image.clear() return # Set live image in UI - capture, _ = capture_region(self) - set_ui_image(self.live_image, capture, False) + capture, _ = self.capture_method.get_frame(self) + set_preview_image(self.live_image, capture, False) def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): """ @@ -379,7 +373,7 @@ def __take_screenshot(self): screenshot_index += 1 # Grab screenshot of capture region - capture, _ = capture_region(self) + capture, _ = self.capture_method.get_frame(self) if not is_valid_image(capture): error_messages.region() return @@ -748,31 +742,19 @@ def __get_capture_for_comparison(self): """ Grab capture region and resize for comparison """ - capture, is_old_image = capture_region(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, lost capture device, etc.) if not is_valid_image(capture): # Try to recover by using the window name - if self.settings_dict["capture_method"] == CaptureMethod.VIDEO_CAPTURE_DEVICE: + if self.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: self.live_image.setText("Waiting for capture device...") else: self.live_image.setText("Trying to recover window...") - hwnd = win32gui.FindWindow(None, self.settings_dict["captured_window_title"]) - # Don't fallback to desktop or whatever window obtained with "" - if win32gui.IsWindow(hwnd) and self.settings_dict["captured_window_title"]: - self.hwnd = hwnd - if self.settings_dict["capture_method"] == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - if self.windows_graphics_capture: - self.windows_graphics_capture.close() - try: - self.windows_graphics_capture = create_windows_graphics_capture(create_for_window(hwnd)) - # Unrecordable hwnd found as the game is crashing - except OSError as exception: - if str(exception).endswith("The parameter is incorrect"): - return None, is_old_image - raise - capture, _ = capture_region(self) + recovered = self.capture_method.recover_window(self.settings_dict["captured_window_title"], self) + if recovered: + capture, _ = self.capture_method.get_frame(self) return (None if not is_valid_image(capture) @@ -817,7 +799,7 @@ def __update_split_image(self, specific_image: Optional[AutoSplitImage] = None): # Get split image self.split_image = specific_image or self.split_images_and_loop_number[0 + self.split_image_number][0] if is_valid_image(self.split_image.bytes): - set_ui_image(self.current_split_image, self.split_image.bytes, True) + set_preview_image(self.current_split_image, self.split_image.bytes, True) 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))) @@ -880,6 +862,28 @@ def exit_program(): exit_program() +def set_preview_image(qlabel: QLabel, image: Optional[cv2.Mat], transparency: bool): + if not is_valid_image(image): + # Clear current pixmap if no image. But don't clear text + if not qlabel.text(): + qlabel.clear() + else: + if transparency: + color_code = cv2.COLOR_BGRA2RGBA + image_format = QtGui.QImage.Format.Format_RGBA8888 + else: + color_code = cv2.COLOR_BGRA2BGR + image_format = QtGui.QImage.Format.Format_BGR888 + + capture = cv2.cvtColor(image, color_code) + height, width, channels = capture.shape + qimage = QtGui.QImage(capture.data, width, height, width * channels, image_format) + qlabel.setPixmap(QtGui.QPixmap(qimage).scaled( + qlabel.size(), + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation)) + + def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" diff --git a/src/WindowsGraphicsCapture.py b/src/WindowsGraphicsCapture.py deleted file mode 100644 index 0447b6b2..00000000 --- a/src/WindowsGraphicsCapture.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncio -from dataclasses import dataclass -from typing import Optional - -import cv2 -from winsdk.windows.graphics import SizeInt32 -from winsdk.windows.graphics.capture import Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession -from winsdk.windows.graphics.directx import DirectXPixelFormat -from winsdk.windows.media.capture import MediaCapture - -from utils import WINDOWS_BUILD_NUMBER - -WGC_NO_BORDER_MIN_BUILD = 20348 - - -@dataclass -class WindowsGraphicsCapture: - size: SizeInt32 - frame_pool: Direct3D11CaptureFramePool - # Prevent session from being garbage collected - session: GraphicsCaptureSession - last_captured_frame: Optional[cv2.Mat] - - def close(self): - self.frame_pool.close() - 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 - # pylint: disable=line-too-long - # "AutoSplit.exe " # noqa: E501 - pass - - -def create_windows_graphics_capture(item: GraphicsCaptureItem): - # 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()) - - if not media_capture.media_capture_settings: - raise OSError("Unable to initialize a Direct3D Device.") - frame_pool = Direct3D11CaptureFramePool.create_free_threaded( - media_capture.media_capture_settings.direct3_d11_device, - DirectXPixelFormat.B8_G8_R8_A8_UINT_NORMALIZED, - 1, - item.size) - if not frame_pool: - raise OSError("Unable to create a frame pool for a capture session.") - session = frame_pool.create_capture_session(item) - if not session: - raise OSError("Unable to create a capture session.") - session.is_cursor_capture_enabled = False - if WINDOWS_BUILD_NUMBER >= WGC_NO_BORDER_MIN_BUILD: - session.is_border_required = False - session.start_capture() - return WindowsGraphicsCapture(item.size, frame_pool, session, None) diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py new file mode 100644 index 00000000..b292e9e1 --- /dev/null +++ b/src/capture_method/BitBltCaptureMethod.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import ctypes +import ctypes.wintypes +from typing import TYPE_CHECKING, Optional, cast + +import cv2 +import numpy as np +import pywintypes +import win32con +import win32ui +from win32 import win32gui + +from capture_method.interface import CaptureMethodInterface +from utils import get_window_bounds + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + +# This is an undocumented nFlag value for PrintWindow +PW_RENDERFULLCONTENT = 0x00000002 + + +class BitBltCaptureMethod(CaptureMethodInterface): + _render_full_content = False + + def close(self, autosplit: AutoSplit): + pass + + def get_frame(self, autosplit: AutoSplit): + selection = autosplit.settings_dict["capture_region"] + hwnd = autosplit.hwnd + image: Optional[cv2.Mat] = None + if not self.check_selected_region_exists(autosplit): + return None, False + + # If the window closes while it's being manipulated, it could cause a crash + try: + window_dc = win32gui.GetWindowDC(hwnd) + dc_object = win32ui.CreateDCFromHandle(window_dc) + + # Causes a 10-15x performance drop. But allows recording hardware accelerated windows + if self._render_full_content: + ctypes.windll.user32.PrintWindow(hwnd, dc_object.GetSafeHdc(), PW_RENDERFULLCONTENT) + + # On Windows there is a shadow around the windows that we need to account for. + left_bounds, top_bounds, *_ = get_window_bounds(hwnd) + + compatible_dc = dc_object.CreateCompatibleDC() + bitmap = win32ui.CreateBitmap() + bitmap.CreateCompatibleBitmap(dc_object, selection["width"], selection["height"]) + compatible_dc.SelectObject(bitmap) + compatible_dc.BitBlt( + (0, 0), + (selection["width"], selection["height"]), + dc_object, + (selection["x"] + left_bounds, selection["y"] + top_bounds), + win32con.SRCCOPY) + image = np.frombuffer(cast(bytes, bitmap.GetBitmapBits(True)), dtype=np.uint8) + image.shape = (selection["height"], selection["width"], 4) + # https://github.com/kaluluosi/pywin32-stubs/issues/5 + except (win32ui.error, pywintypes.error): # pyright: ignore [reportGeneralTypeIssues] pylint: disable=no-member + return None, False + # We already obtained the image, so we can ignore errors during cleanup + try: + dc_object.DeleteDC() + compatible_dc.DeleteDC() + win32gui.ReleaseDC(hwnd, window_dc) + win32gui.DeleteObject(bitmap.GetHandle()) # pyright: ignore [reportGeneralTypeIssues] + # https://github.com/kaluluosi/pywin32-stubs/issues/5 + except win32ui.error: # pyright: ignore [reportGeneralTypeIssues] + pass + return image, False + + def recover_window(self, captured_window_title: str, autosplit: AutoSplit): + hwnd = win32gui.FindWindow(None, captured_window_title) + # Don't fallback to desktop or whatever window obtained with "" + if not win32gui.IsWindow(hwnd) or not captured_window_title: + return False + autosplit.hwnd = hwnd + return self.check_selected_region_exists(autosplit) + + def check_selected_region_exists(self, autosplit: AutoSplit): + return bool(win32gui.IsWindow(autosplit.hwnd) and win32gui.GetWindowText(autosplit.hwnd)) diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py new file mode 100644 index 00000000..fd216d3e --- /dev/null +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import ctypes +import ctypes.wintypes +from typing import TYPE_CHECKING + +import cv2 +import d3dshot +import win32con +from win32 import win32gui + +from capture_method.BitBltCaptureMethod import BitBltCaptureMethod +from utils import get_window_bounds + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + +desktop_duplication = d3dshot.create(capture_output="numpy") + + +class DesktopDuplicationCaptureMethod(BitBltCaptureMethod): # pylint: disable=too-few-public-methods + def get_frame(self, autosplit: AutoSplit): + selection = autosplit.settings_dict["capture_region"] + hwnd = autosplit.hwnd + hmonitor = ctypes.windll.user32.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) + if not hmonitor or not win32gui.IsWindow(hwnd): + return None, False + + left_bounds, top_bounds, *_ = get_window_bounds(hwnd) + desktop_duplication.display = [ + display for display + in desktop_duplication.displays + if display.hmonitor == hmonitor][0] + offset_x, offset_y, *_ = win32gui.GetWindowRect(hwnd) + offset_x -= desktop_duplication.display.position["left"] + offset_y -= desktop_duplication.display.position["top"] + left = selection["x"] + offset_x + left_bounds + top = selection["y"] + offset_y + top_bounds + right = selection["width"] + left + bottom = selection["height"] + top + screenshot = desktop_duplication.screenshot((left, top, right, bottom)) + return cv2.cvtColor(screenshot, cv2.COLOR_RGBA2BGRA), False diff --git a/src/capture_method/ForceFullContentRenderingCaptureMethod.py b/src/capture_method/ForceFullContentRenderingCaptureMethod.py new file mode 100644 index 00000000..ea2acd76 --- /dev/null +++ b/src/capture_method/ForceFullContentRenderingCaptureMethod.py @@ -0,0 +1,5 @@ +from capture_method.BitBltCaptureMethod import BitBltCaptureMethod + + +class ForceFullContentRenderingCaptureMethod(BitBltCaptureMethod): # pylint: disable=too-few-public-methods + _render_full_content = True diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py new file mode 100644 index 00000000..927ebd2c --- /dev/null +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import cv2 + +from capture_method.interface import CaptureMethodInterface + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +class VideoCaptureDeviceCaptureMethod(CaptureMethodInterface): # pylint: disable=too-few-public-methods + capture_device: cv2.VideoCapture + + def __init__(self, autosplit: AutoSplit): + super().__init__() + self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) + self.capture_device.setExceptionMode(True) + + 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 + result, image = self.capture_device.read() + if not result: + return None, False + # 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) + image = image[ + y:y + selection["height"], + x:x + selection["width"], + ] + return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA), False + + def check_selected_region_exists(self, autosplit: AutoSplit): + return bool(self.capture_device.isOpened()) diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py new file mode 100644 index 00000000..5e9bbe9e --- /dev/null +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Optional, cast + +import cv2 +import numpy as np +from win32 import win32gui +from winsdk.windows.graphics import SizeInt32 +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.imaging import BitmapBufferAccessMode, SoftwareBitmap +from winsdk.windows.media.capture import MediaCapture + +from capture_method.interface import CaptureMethodInterface +from utils import WINDOWS_BUILD_NUMBER + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + +WGC_NO_BORDER_MIN_BUILD = 20348 + + +class WindowsGraphicsCaptureMethod(CaptureMethodInterface): + size: SizeInt32 + frame_pool: Optional[Direct3D11CaptureFramePool] = None + session: Optional[GraphicsCaptureSession] = None + """This is stored to prevent session from being garbage collected""" + last_captured_frame: Optional[cv2.Mat] = None + + def __init__(self, autosplit: AutoSplit): + super().__init__(autosplit) + if not self.check_selected_region_exists(autosplit): + return + # 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() + item = create_for_window(autosplit.hwnd) + + async def coroutine(): + await (media_capture.initialize_async() or asyncio.sleep(0)) + asyncio.run(coroutine()) + + if not media_capture.media_capture_settings: + raise OSError("Unable to initialize a Direct3D Device.") + frame_pool = Direct3D11CaptureFramePool.create_free_threaded( + media_capture.media_capture_settings.direct3_d11_device, + DirectXPixelFormat.B8_G8_R8_A8_UINT_NORMALIZED, + 1, + item.size) + if not frame_pool: + raise OSError("Unable to create a frame pool for a capture session.") + session = frame_pool.create_capture_session(item) + if not session: + raise OSError("Unable to create a capture session.") + session.is_cursor_capture_enabled = False + if WINDOWS_BUILD_NUMBER >= WGC_NO_BORDER_MIN_BUILD: + session.is_border_required = False + session.start_capture() + + self.session = session + self.size = item.size + self.frame_pool = frame_pool + + def close(self, autosplit: AutoSplit): + if self.frame_pool: + self.frame_pool.close() + self.frame_pool = None + if self.session: + 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 + # pylint: disable=line-too-long + # "AutoSplit.exe " # noqa: E501 + pass + self.session = None + + def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: + selection = autosplit.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(autosplit) or not self.frame_pool or not self.session: + return None, False + + try: + frame = self.frame_pool.try_get_next_frame() + # Frame pool is closed + except OSError: + return None, False + if not frame: + return self.last_captured_frame, True + + async def coroutine(): + return await (SoftwareBitmap.create_copy_from_surface_async(frame.surface) or asyncio.sleep(0, None)) + try: + software_bitmap = asyncio.run(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_captured_frame, True + raise + + if not software_bitmap: + # HACK: Can happen when starting the region selector + return self.last_captured_frame, True + # raise ValueError("Unable to convert Direct3D11CaptureFrame to SoftwareBitmap.") + bitmap_buffer = software_bitmap.lock_buffer(BitmapBufferAccessMode.READ_WRITE) + if not bitmap_buffer: + raise ValueError("Unable to obtain the BitmapBuffer from SoftwareBitmap.") + reference = bitmap_buffer.create_reference() + image = np.frombuffer(cast(bytes, reference), dtype=np.uint8) + image.shape = (self.size.height, self.size.width, 4) + image = image[ + selection["y"]:selection["y"] + selection["height"], + selection["x"]:selection["x"] + selection["width"], + ] + self.last_captured_frame = image + return image, False + + def recover_window(self, captured_window_title: str, autosplit: AutoSplit): + hwnd = win32gui.FindWindow(None, captured_window_title) + # Don't fallback to desktop or whatever window obtained with "" + if not win32gui.IsWindow(hwnd) or not captured_window_title: + return False + autosplit.hwnd = hwnd + self.close(autosplit) + try: + self.__init__(autosplit) # pylint: disable=unnecessary-dunder-call + # Unrecordable hwnd found as the game is crashing + except OSError as exception: + if str(exception).endswith("The parameter is incorrect"): + return False + raise + return self.check_selected_region_exists(autosplit) + + def check_selected_region_exists(self, autosplit: AutoSplit): + return bool(win32gui.IsWindow(autosplit.hwnd) and win32gui.GetWindowText(autosplit.hwnd)) diff --git a/src/CaptureMethod.py b/src/capture_method/__init__.py similarity index 71% rename from src/CaptureMethod.py rename to src/capture_method/__init__.py index e8f28ace..3b74eb99 100644 --- a/src/CaptureMethod.py +++ b/src/capture_method/__init__.py @@ -1,33 +1,43 @@ +from __future__ import annotations + import asyncio from collections import OrderedDict from dataclasses import dataclass from enum import Enum, EnumMeta, unique +from typing import TYPE_CHECKING, TypedDict import cv2 from pygrabber.dshow_graph import FilterGraph from winsdk.windows.media.capture import MediaCapture +from capture_method.BitBltCaptureMethod import BitBltCaptureMethod +from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod +from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod +from capture_method.interface import CaptureMethodInterface +from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod +from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod from utils import WINDOWS_BUILD_NUMBER +if TYPE_CHECKING: + from AutoSplit import AutoSplit + WGC_MIN_BUILD = 17134 """https://docs.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscapturepicker#applies-to""" -def test_for_media_capture(): - async def coroutine(): - return await (MediaCapture().initialize_async() or asyncio.sleep(0)) - try: - asyncio.run(coroutine()) - return True - except OSError: - return False +class Region(TypedDict): + x: int + y: int + width: int + height: int @dataclass -class DisplayCaptureMethodInfo(): +class CaptureMethodInfo(): name: str short_description: str description: str + implementation: type[CaptureMethodInterface] class CaptureMethodMeta(EnumMeta): @@ -42,7 +52,7 @@ def __contains__(self, other: str): @unique -class CaptureMethod(Enum, metaclass=CaptureMethodMeta): +class CaptureMethodEnum(Enum, metaclass=CaptureMethodMeta): # Allow TOML to save as a simple string def __repr__(self): return self.value @@ -63,15 +73,15 @@ def __hash__(self): VIDEO_CAPTURE_DEVICE = "VIDEO_CAPTURE_DEVICE" -class DisplayCaptureMethodDict(OrderedDict[CaptureMethod, DisplayCaptureMethodInfo]): +class CaptureMethodDict(OrderedDict[CaptureMethodEnum, CaptureMethodInfo]): def get_method_by_index(self, index: int): if index < 0: return next(iter(self)) return list(self.keys())[index] -CAPTURE_METHODS = DisplayCaptureMethodDict({ - CaptureMethod.BITBLT: DisplayCaptureMethodInfo( +CAPTURE_METHODS = CaptureMethodDict({ + CaptureMethodEnum.BITBLT: CaptureMethodInfo( name="BitBlt", short_description="fastest, least compatible", description=( @@ -79,8 +89,10 @@ def get_method_by_index(self, index: int): "\nOpenGL, Hardware Accelerated or Exclusive Fullscreen windows. " "\nThe smaller the selected region, the more efficient it is. " ), + + implementation=BitBltCaptureMethod, ), - CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: DisplayCaptureMethodInfo( + CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE: CaptureMethodInfo( name="Windows Graphics Capture", short_description="fast, most compatible, capped at 60fps", description=( @@ -91,8 +103,9 @@ def get_method_by_index(self, index: int): "\nAdds a yellow border on Windows 10 (not on Windows 11)." "\nCaps at around 60 FPS. " ), + implementation=WindowsGraphicsCaptureMethod, ), - CaptureMethod.DESKTOP_DUPLICATION: DisplayCaptureMethodInfo( + CaptureMethodEnum.DESKTOP_DUPLICATION: CaptureMethodInfo( name="Direct3D Desktop Duplication", short_description="slower, bound to display", description=( @@ -101,8 +114,9 @@ def get_method_by_index(self, index: int): "\nAbout 10-15x slower than BitBlt. Not affected by window size. " "\nOverlapping windows will show up and can't record across displays. " ), + implementation=DesktopDuplicationCaptureMethod, ), - CaptureMethod.PRINTWINDOW_RENDERFULLCONTENT: DisplayCaptureMethodInfo( + CaptureMethodEnum.PRINTWINDOW_RENDERFULLCONTENT: CaptureMethodInfo( name="Force Full Content Rendering", short_description="very slow, can affect rendering pipeline", description=( @@ -111,8 +125,9 @@ def get_method_by_index(self, index: int): "\nAbout 10-15x slower than BitBlt based on original window size " "\nand can mess up some applications' rendering pipelines. " ), + implementation=ForceFullContentRenderingCaptureMethod, ), - CaptureMethod.VIDEO_CAPTURE_DEVICE: DisplayCaptureMethodInfo( + CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: CaptureMethodInfo( name="Video Capture Device", short_description="very slow, see below", description=( @@ -122,17 +137,39 @@ def get_method_by_index(self, index: int): "\nIf you want to use this with OBS' Virtual Camera, use the Virtualcam plugin instead " "\nhttps://obsproject.com/forum/resources/obs-virtualcam.949/." ), + implementation=VideoCaptureDeviceCaptureMethod, ), }) +def test_for_media_capture(): + async def coroutine(): + return await (MediaCapture().initialize_async() or asyncio.sleep(0)) + try: + asyncio.run(coroutine()) + return True + except OSError: + return False + + # 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 requires at least one CaptureDevice or not test_for_media_capture() ): - CAPTURE_METHODS.pop(CaptureMethod.WINDOWS_GRAPHICS_CAPTURE) + CAPTURE_METHODS.pop(CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE) + + +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) + 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) @dataclass @@ -156,7 +193,7 @@ async def get_camera_info(index: int, device_name: str): video_capture.grab() except cv2.error as error: # pyright: ignore [reportUnknownVariableType] return CameraInfo(index, device_name, True, backend) \ - if error.code == cv2.Error.STS_ERROR \ + if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) \ else None finally: video_capture.release() diff --git a/src/capture_method/interface.py b/src/capture_method/interface.py new file mode 100644 index 00000000..24804129 --- /dev/null +++ b/src/capture_method/interface.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import cv2 + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +class CaptureMethodInterface(): + def __init__(self, autosplit: Optional[AutoSplit] = None): + pass + + def reinitialize(self, autosplit: AutoSplit): + self.close(autosplit) + self.__init__(autosplit) # pylint: disable=unnecessary-dunder-call + + def close(self, autosplit: AutoSplit): + pass + + def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: + """ + 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 + """ + raise NotImplementedError() + + def recover_window(self, captured_window_title: str, autosplit: AutoSplit) -> bool: + raise NotImplementedError() + + def check_selected_region_exists(self, autosplit: AutoSplit) -> bool: + raise NotImplementedError() diff --git a/src/menu_bar.py b/src/menu_bar.py index 4b0c510e..28f3237c 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -3,22 +3,19 @@ import asyncio import threading import webbrowser -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Union, cast -import cv2 import requests from packaging.version import parse as version_parse from PyQt6 import QtCore, QtWidgets from requests.exceptions import RequestException -from win32 import win32gui -from winsdk.windows.graphics.capture.interop import create_for_window import error_messages import user_profile -from CaptureMethod import CAPTURE_METHODS, CameraInfo, CaptureMethod, get_all_video_capture_devices +from capture_method import (CAPTURE_METHODS, CameraInfo, CaptureMethodEnum, change_capture_method, + get_all_video_capture_devices) from gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa: F401 from hotkeys import HOTKEYS, Hotkeys, set_hotkey -from region_selection import create_windows_graphics_capture from utils import AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, WINDOWS_BUILD_NUMBER, decimal if TYPE_CHECKING: @@ -100,12 +97,12 @@ def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): autosplit.CheckForUpdatesThread.start() -def get_capture_method_index(capture_method: Union[str, CaptureMethod]): +def get_capture_method_index(capture_method: Union[str, CaptureMethodEnum]): """ Returns 0 if the capture_method is invalid or unsupported """ try: - return list(CAPTURE_METHODS.keys()).index(cast(CaptureMethod, capture_method)) + return list(CAPTURE_METHODS.keys()).index(cast(CaptureMethodEnum, capture_method)) except ValueError: return 0 @@ -142,40 +139,18 @@ def get_capture_device_index(self, capture_device_id: int): def __capture_method_changed(self): selected_capture_method = CAPTURE_METHODS.get_method_by_index(self.capture_method_combobox.currentIndex()) - # Release or start video capture device - self.__capture_device_changed(selected_capture_method) - if self.autosplit.windows_graphics_capture: - self.autosplit.windows_graphics_capture.close() - self.autosplit.windows_graphics_capture = None - if selected_capture_method == CaptureMethod.VIDEO_CAPTURE_DEVICE: - self.autosplit.select_region_button.setDisabled(True) - self.autosplit.select_window_button.setDisabled(True) - else: - self.autosplit.select_region_button.setDisabled(False) - self.autosplit.select_window_button.setDisabled(False) - # Recover window from name - hwnd = win32gui.FindWindow(None, self.autosplit.settings_dict["captured_window_title"]) - # Don't fallback to desktop or whatever window obtained with "" - if win32gui.IsWindow(hwnd) and self.autosplit.settings_dict["captured_window_title"]: - self.autosplit.hwnd = hwnd - if selected_capture_method == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - self.autosplit.windows_graphics_capture = create_windows_graphics_capture(create_for_window(hwnd)) + change_capture_method(selected_capture_method, self.autosplit) return selected_capture_method - def __capture_device_changed(self, current_capture_method: Optional[Union[CaptureMethod, str]] = None): - current_capture_method = current_capture_method or self.autosplit.settings_dict["capture_method"] - # Always release the previous capture device - if self.autosplit.capture_device: - self.autosplit.capture_device.release() - self.autosplit.capture_device = None + def __capture_device_changed(self): device_index = self.capture_device_combobox.currentIndex() if device_index == -1: - return None + return capture_device = self.__video_capture_devices[device_index] - if current_capture_method == CaptureMethod.VIDEO_CAPTURE_DEVICE: - self.autosplit.settings_dict["capture_device_name"] = capture_device.name - self.autosplit.capture_device = cv2.VideoCapture(capture_device.device_id) - return capture_device.device_id + self.autosplit.settings_dict["capture_device_name"] = capture_device.name + self.autosplit.settings_dict["capture_device_id"] = capture_device.device_id + if self.autosplit.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: + change_capture_method(CaptureMethodEnum.VIDEO_CAPTURE_DEVICE, self.autosplit) async def __set_all_capture_devices(self): self.__video_capture_devices = await get_all_video_capture_devices() @@ -183,7 +158,9 @@ async def __set_all_capture_devices(self): for i in range(self.capture_device_combobox.count()): self.capture_device_combobox.removeItem(i) self.capture_device_combobox.addItems([ - f"* {device.name} [{device.backend}]{' (occupied)' if device.occupied else ''}" + f"* {device.name}" + + (f" [{device.backend}]" if device.backend else "") + + (" (occupied)" if device.occupied else "") for device in self.__video_capture_devices]) self.capture_device_combobox.setEnabled(True) self.capture_device_combobox.setCurrentIndex( @@ -266,9 +243,7 @@ def hotkey_connect(hotkey: Hotkeys): self.capture_method_combobox.currentIndexChanged.connect(lambda: self.__set_value( "capture_method", self.__capture_method_changed())) - self.capture_device_combobox.currentIndexChanged.connect(lambda: self.__set_value( - "capture_device_id", - self.__capture_device_changed())) + self.capture_device_combobox.currentIndexChanged.connect(self.__capture_device_changed) # Image Settings self.default_comparison_method.currentIndexChanged.connect(lambda: self.__set_value( diff --git a/src/region_capture.py b/src/region_capture.py deleted file mode 100644 index 266ec5f4..00000000 --- a/src/region_capture.py +++ /dev/null @@ -1,225 +0,0 @@ -from __future__ import annotations - -import asyncio -import ctypes -import ctypes.wintypes -from typing import TYPE_CHECKING, Optional, TypedDict, cast - -import cv2 -import d3dshot -import numpy as np -import pywintypes -import win32con -import win32ui -from PyQt6 import QtCore, QtGui -from PyQt6.QtWidgets import QLabel -from win32 import win32gui -from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap - -from CaptureMethod import CaptureMethod -from utils import is_valid_image -from WindowsGraphicsCapture import WindowsGraphicsCapture - -if TYPE_CHECKING: - from AutoSplit import AutoSplit - -# This is an undocumented nFlag value for PrintWindow -PW_RENDERFULLCONTENT = 0x00000002 -DWMWA_EXTENDED_FRAME_BOUNDS = 9 - - -desktop_duplication = d3dshot.create(capture_output="numpy") - - -class Region(TypedDict): - x: int - y: int - width: int - height: int - - -def get_window_bounds(hwnd: int): - extended_frame_bounds = ctypes.wintypes.RECT() - ctypes.windll.dwmapi.DwmGetWindowAttribute( - hwnd, - DWMWA_EXTENDED_FRAME_BOUNDS, - ctypes.byref(extended_frame_bounds), - ctypes.sizeof(extended_frame_bounds)) - - window_rect = win32gui.GetWindowRect(hwnd) - window_left_bounds = cast(int, extended_frame_bounds.left) - window_rect[0] - window_top_bounds = cast(int, extended_frame_bounds.top) - window_rect[1] - window_width = cast(int, extended_frame_bounds.right) - cast(int, extended_frame_bounds.left) - window_height = cast(int, extended_frame_bounds.bottom) - cast(int, extended_frame_bounds.top) - return window_left_bounds, window_top_bounds, window_width, window_height - - -def __bit_blt_capture(hwnd: int, selection: Region, render_full_content: bool = False): - image: Optional[cv2.Mat] = None - # If the window closes while it's being manipulated, it could cause a crash - try: - window_dc = win32gui.GetWindowDC(hwnd) - dc_object = win32ui.CreateDCFromHandle(window_dc) - - # Causes a 10-15x performance drop. But allows recording hardware accelerated windows - if render_full_content: - ctypes.windll.user32.PrintWindow(hwnd, dc_object.GetSafeHdc(), PW_RENDERFULLCONTENT) - - # On Windows there is a shadow around the windows that we need to account for. - left_bounds, top_bounds, *_ = get_window_bounds(hwnd) - - compatible_dc = dc_object.CreateCompatibleDC() - bitmap = win32ui.CreateBitmap() - bitmap.CreateCompatibleBitmap(dc_object, selection["width"], selection["height"]) - compatible_dc.SelectObject(bitmap) - compatible_dc.BitBlt( - (0, 0), - (selection["width"], selection["height"]), - dc_object, - (selection["x"] + left_bounds, selection["y"] + top_bounds), - win32con.SRCCOPY) - image = np.frombuffer(cast(bytes, bitmap.GetBitmapBits(True)), dtype=np.uint8) - image.shape = (selection["height"], selection["width"], 4) - # https://github.com/kaluluosi/pywin32-stubs/issues/5 - except (win32ui.error, pywintypes.error): # pyright: ignore [reportGeneralTypeIssues] pylint: disable=no-member - return None - # We already obtained the image, so we can ignore errors during cleanup - try: - dc_object.DeleteDC() - compatible_dc.DeleteDC() - win32gui.ReleaseDC(hwnd, window_dc) - win32gui.DeleteObject(bitmap.GetHandle()) - # https://github.com/kaluluosi/pywin32-stubs/issues/5 - except win32ui.error: # pyright: ignore [reportGeneralTypeIssues] - pass - return image - - -def __d3d_capture(hwnd: int, selection: Region): - hmonitor = ctypes.windll.user32.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) - if not hmonitor: - return None - - left_bounds, top_bounds, *_ = get_window_bounds(hwnd) - desktop_duplication.display = [ - display for display - in desktop_duplication.displays - if display.hmonitor == hmonitor][0] - offset_x, offset_y, *_ = win32gui.GetWindowRect(hwnd) - offset_x -= desktop_duplication.display.position["left"] - offset_y -= desktop_duplication.display.position["top"] - left = selection["x"] + offset_x + left_bounds - top = selection["y"] + offset_y + top_bounds - right = selection["width"] + left - bottom = selection["height"] + top - screenshot = desktop_duplication.screenshot((left, top, right, bottom)) - return cv2.cvtColor(screenshot, cv2.COLOR_RGBA2BGRA) - - -def __windows_graphics_capture(windows_graphics_capture: Optional[WindowsGraphicsCapture], selection: Region): - if not windows_graphics_capture or not windows_graphics_capture.frame_pool: - return None, False - - try: - frame = windows_graphics_capture.frame_pool.try_get_next_frame() - # Frame pool is closed - except OSError: - return None, False - if not frame: - return windows_graphics_capture.last_captured_frame, True - - async def coroutine(): - return await (SoftwareBitmap.create_copy_from_surface_async(frame.surface) or asyncio.sleep(0, None)) - try: - software_bitmap = asyncio.run(coroutine()) - except SystemError as exception: - # HACK: can happen when closing the GraphicsCapturePicker - if str(exception).endswith("returned a result with an error set"): - return windows_graphics_capture.last_captured_frame, True - raise - - if not software_bitmap: - # HACK: Can happen when starting the region selector - return windows_graphics_capture.last_captured_frame, True - # raise ValueError("Unable to convert Direct3D11CaptureFrame to SoftwareBitmap.") - bitmap_buffer = software_bitmap.lock_buffer(BitmapBufferAccessMode.READ_WRITE) - if not bitmap_buffer: - raise ValueError("Unable to obtain the BitmapBuffer from SoftwareBitmap.") - reference = bitmap_buffer.create_reference() - image = np.frombuffer(cast(bytes, reference), dtype=np.uint8) - image.shape = (windows_graphics_capture.size.height, windows_graphics_capture.size.width, 4) - image = image[ - selection["y"]:selection["y"] + selection["height"], - selection["x"]:selection["x"] + selection["width"], - ] - windows_graphics_capture.last_captured_frame = image - return image, False - - -def __camera_capture(capture_device: Optional[cv2.VideoCapture], selection: Region): - if not capture_device: - return None - result, image = capture_device.read() - if not result: - return None - # 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) - image = image[ - y:selection["height"] + y, - x:selection["width"] + x, - ] - return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) - - -def capture_region(autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: - """ - Captures an image of the region for a window matching the given - parameters of the bounding box - - @param hwnd: Handle to the window being captured - @param selection: The coordinates of the region - @return: The image of the region in the window in BGRA format - """ - capture_method = autosplit.settings_dict["capture_method"] - selection = autosplit.settings_dict["capture_region"] - - if capture_method == CaptureMethod.VIDEO_CAPTURE_DEVICE: - return __camera_capture(autosplit.capture_device, selection), False - - if not win32gui.IsWindow(autosplit.hwnd): - return None, False - - if capture_method == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - image, is_old_image = __windows_graphics_capture(autosplit.windows_graphics_capture, selection) - return (None, False) \ - if is_old_image and not win32gui.IsWindow(autosplit.hwnd) \ - else (image, is_old_image) - - if capture_method == CaptureMethod.DESKTOP_DUPLICATION: - return __d3d_capture(autosplit.hwnd, selection), False - - return __bit_blt_capture(autosplit.hwnd, selection, capture_method - == CaptureMethod.PRINTWINDOW_RENDERFULLCONTENT), False - - -def set_ui_image(qlabel: QLabel, image: Optional[cv2.Mat], transparency: bool): - if not is_valid_image(image): - # Clear current pixmap if no image. But don't clear text - if not qlabel.text(): - qlabel.clear() - else: - if transparency: - color_code = cv2.COLOR_BGRA2RGBA - image_format = QtGui.QImage.Format.Format_RGBA8888 - else: - color_code = cv2.COLOR_BGRA2BGR - image_format = QtGui.QImage.Format.Format_BGR888 - - capture = cv2.cvtColor(image, color_code) - height, width, channels = capture.shape - qimage = QtGui.QImage(capture.data, width, height, width * channels, image_format) - qlabel.setPixmap(QtGui.QPixmap(qimage).scaled( - qlabel.size(), - QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation)) diff --git a/src/region_selection.py b/src/region_selection.py index 95e49a1c..cf9809f1 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -15,13 +15,9 @@ from winsdk._winrt import initialize_with_window from winsdk.windows.foundation import AsyncStatus, IAsyncOperation from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker -from winsdk.windows.graphics.capture.interop import create_for_window import error_messages -from CaptureMethod import CaptureMethod -from region_capture import capture_region, get_window_bounds -from utils import is_valid_image -from WindowsGraphicsCapture import create_windows_graphics_capture +from utils import get_window_bounds, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -67,9 +63,7 @@ def callback(async_operation: IAsyncOperation[GraphicsCaptureItem], async_status if not item: return autosplit.settings_dict["captured_window_title"] = item.display_name - if autosplit.windows_graphics_capture: - autosplit.windows_graphics_capture.close() - autosplit.windows_graphics_capture = create_windows_graphics_capture(item) + autosplit.capture_method.reinitialize(autosplit) picker = GraphicsCapturePicker() initialize_with_window(picker, int(autosplit.effectiveWinId())) @@ -103,10 +97,7 @@ def select_region(autosplit: AutoSplit): autosplit.hwnd = hwnd autosplit.settings_dict["captured_window_title"] = window_text - if autosplit.settings_dict["capture_method"] == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - if autosplit.windows_graphics_capture: - autosplit.windows_graphics_capture.close() - autosplit.windows_graphics_capture = create_windows_graphics_capture(create_for_window(hwnd)) + autosplit.capture_method.reinitialize(autosplit) left_bounds, top_bounds, *_ = get_window_bounds(hwnd) window_x, window_y, *_ = win32gui.GetWindowRect(hwnd) @@ -139,10 +130,7 @@ def select_window(autosplit: AutoSplit): autosplit.hwnd = hwnd autosplit.settings_dict["captured_window_title"] = window_text - if autosplit.settings_dict["capture_method"] == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - if autosplit.windows_graphics_capture: - autosplit.windows_graphics_capture.close() - autosplit.windows_graphics_capture = create_windows_graphics_capture(create_for_window(hwnd)) + autosplit.capture_method.reinitialize(autosplit) # Exlude the borders and titlebar from the window selection. To only get the client area. _, __, window_width, window_height = get_window_bounds(hwnd) @@ -174,7 +162,7 @@ def __get_window_from_point(x: int, y: int): def align_region(autosplit: AutoSplit): # Check to see if a region has been set - if not check_selected_region_exists(autosplit): + if not autosplit.capture_method.check_selected_region_exists(autosplit): error_messages.region() return # This is the image used for aligning the capture region to the best fit for the user. @@ -198,7 +186,7 @@ def align_region(autosplit: AutoSplit): # Obtaining the capture of a region which contains the # subregion being searched for to align the image. - capture, _ = capture_region(autosplit) + capture, _ = autosplit.capture_method.get_frame(autosplit) if not is_valid_image(capture): error_messages.region() @@ -282,21 +270,13 @@ def validate_before_parsing(autosplit: AutoSplit, show_error: bool = True, check 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 check_selected_region_exists(autosplit): + elif not autosplit.capture_method.check_selected_region_exists(autosplit): error = error_messages.region if error and show_error: error() return not error -def check_selected_region_exists(autosplit: AutoSplit): - if autosplit.settings_dict["capture_method"] == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - return bool(autosplit.windows_graphics_capture) - if autosplit.settings_dict["capture_method"] == CaptureMethod.VIDEO_CAPTURE_DEVICE: - return bool(autosplit.capture_device) - return bool(win32gui.IsWindow(autosplit.hwnd) and win32gui.GetWindowText(autosplit.hwnd)) - - class BaseSelectWidget(QtWidgets.QWidget): _x = 0 _y = 0 diff --git a/src/user_profile.py b/src/user_profile.py index 7adf624c..ff7df78b 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -3,19 +3,14 @@ import os from typing import TYPE_CHECKING, TypedDict, Union, cast -import cv2 import keyboard import toml from PyQt6 import QtCore, QtWidgets -from win32 import win32gui -from winsdk.windows.graphics.capture.interop import create_for_window import error_messages -from CaptureMethod import CAPTURE_METHODS, CaptureMethod +from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method from gen import design from hotkeys import HOTKEYS, set_hotkey -from region_capture import Region -from region_selection import create_windows_graphics_capture from utils import auto_split_directory if TYPE_CHECKING: @@ -30,7 +25,7 @@ class UserProfileDict(TypedDict): pause_hotkey: str fps_limit: int live_capture_region: bool - capture_method: Union[str, CaptureMethod] + capture_method: Union[str, CaptureMethodEnum] capture_device_id: int capture_device_name: str default_comparison_method: int @@ -132,30 +127,21 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str autosplit.show_error_signal.emit(error_messages.invalid_settings) return False - if autosplit.settings_dict["capture_method"] == CaptureMethod.VIDEO_CAPTURE_DEVICE: - autosplit.select_region_button.setDisabled(True) - autosplit.select_window_button.setDisabled(True) - autosplit.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) - keyboard.unhook_all() 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["captured_window_title"]: - hwnd = win32gui.FindWindow(None, autosplit.settings_dict["captured_window_title"]) - # Don't fallback to desktop or whatever window obtained with "" - if win32gui.IsWindow(hwnd) and autosplit.settings_dict["captured_window_title"]: - autosplit.hwnd = hwnd - if autosplit.settings_dict["capture_method"] == CaptureMethod.WINDOWS_GRAPHICS_CAPTURE: - if autosplit.windows_graphics_capture: - autosplit.windows_graphics_capture.close() - autosplit.windows_graphics_capture = create_windows_graphics_capture(create_for_window(hwnd)) - else: - autosplit.live_image.setText("Reload settings after opening" - + f'\n"{autosplit.settings_dict["captured_window_title"]}"' - + "\nto automatically load Capture Region") + change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) + if ( + not autosplit.capture_method.check_selected_region_exists(autosplit) + and autosplit.settings_dict["captured_window_title"] + ): + autosplit.live_image.setText("Reload settings after opening" + + f'\n"{autosplit.settings_dict["captured_window_title"]}"' + + "\nto automatically load Capture Region") + return True diff --git a/src/utils.py b/src/utils.py index 5bf75eaa..ab40e1a3 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,12 +1,17 @@ import asyncio +import ctypes +import ctypes.wintypes import os import sys from collections.abc import Callable from platform import version -from typing import Any, Optional, Union +from typing import Any, Optional, Union, cast import cv2 from typing_extensions import TypeGuard +from win32 import win32gui + +DWMWA_EXTENDED_FRAME_BOUNDS = 9 def decimal(value: Union[int, float]): @@ -29,6 +34,22 @@ def is_valid_image(image: Optional[cv2.Mat]) -> TypeGuard[cv2.Mat]: return image is not None and bool(image.size) +def get_window_bounds(hwnd: int): + extended_frame_bounds = ctypes.wintypes.RECT() + ctypes.windll.dwmapi.DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + ctypes.byref(extended_frame_bounds), + ctypes.sizeof(extended_frame_bounds)) + + window_rect = win32gui.GetWindowRect(hwnd) + window_left_bounds = cast(int, extended_frame_bounds.left) - window_rect[0] + window_top_bounds = cast(int, extended_frame_bounds.top) - window_rect[1] + window_width = cast(int, extended_frame_bounds.right) - cast(int, extended_frame_bounds.left) + window_height = cast(int, extended_frame_bounds.bottom) - cast(int, extended_frame_bounds.top) + return window_left_bounds, window_top_bounds, window_width, window_height + + def fire_and_forget(func: Callable[..., None]): def wrapped(*args: Any, **kwargs: Any): return asyncio.get_event_loop().run_in_executor(None, func, *args, *kwargs) diff --git a/typings/cv2-stubs/__init__.pyi b/typings/cv2-stubs/__init__.pyi index 1638dd1c..550c3001 100644 --- a/typings/cv2-stubs/__init__.pyi +++ b/typings/cv2-stubs/__init__.pyi @@ -1,8 +1,9 @@ # Python: 3.8.0 (tags/v3.8.0:fa919fd, Oct 14 2019, 19:37:50) [MSC v.1916 64 bit (AMD64)] # Library: cv2, version: 4.4.0 # Module: cv2.cv2, version: 4.4.0 -import typing import builtins as _mod_builtins +import typing + import cv2 as _mod_cv2 import numpy as np @@ -1586,6 +1587,7 @@ class VideoCapture(): def release(self): ... def setExceptionMode(self, error: bool): ... def getBackendName(self) -> str: ... + def isOpened(self) -> bool: ... VideoWriter = _mod_cv2.VideoWriter def VideoWriter_fourcc(c1, c2, c3, c4) -> typing.Any: @@ -3109,4 +3111,4 @@ def writeOpticalFlow(path, flow) -> typing.Any: 'writeOpticalFlow(path, flow) -> retval\n. @brief Write a .flo to disk\n. \n. @param path Path to the file to be written\n. @param flow Flow field to be stored\n. \n. The function stores a flow field in a file, returns true on success, false otherwise.\n. The flow field must be a 2-channel, floating-point matrix (CV_32FC2). First channel corresponds\n. to the flow in the horizontal direction (u), second - vertical (v).' ... -def __getattr__(name) -> typing.Any: ... #incomplete \ No newline at end of file +def __getattr__(name) -> typing.Any: ... #incomplete From 3d72389aedb16d195e5fb5350b88af7e2b04d683 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 13 Jul 2022 00:39:57 -0400 Subject: [PATCH 05/28] Optimized VideoCaptureDevice by threading it --- .../VideoCaptureDeviceCaptureMethod.py | 46 ++++++++++++++++--- src/error_messages.py | 6 ++- src/hotkeys.py | 4 +- src/menu_bar.py | 4 +- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 927ebd2c..9c197232 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -1,33 +1,64 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from threading import Event, Thread +from typing import TYPE_CHECKING, Optional import cv2 from capture_method.interface import CaptureMethodInterface +from error_messages import CREATE_NEW_ISSUE_MESSAGE, exception_traceback +from utils import is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit -class VideoCaptureDeviceCaptureMethod(CaptureMethodInterface): # pylint: disable=too-few-public-methods +class VideoCaptureDeviceCaptureMethod(CaptureMethodInterface): capture_device: cv2.VideoCapture + capture_thread: Optional[Thread] + last_captured_frame: Optional[cv2.Mat] = None + is_old_image = False + stop_thread = Event() + + def __read_loop(self, autosplit: AutoSplit): + try: + while not self.stop_thread.is_set(): + result, image = self.capture_device.read() + self.last_captured_frame = image if result else None + self.is_old_image = False + except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here + error = exception + autosplit.show_error_signal.emit(lambda: exception_traceback( + "AutoSplit encountered an unhandled exception while trying to grab a frame and has stopped capture. " + + CREATE_NEW_ISSUE_MESSAGE, + error)) def __init__(self, autosplit: AutoSplit): super().__init__() self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) self.capture_device.setExceptionMode(True) + self.stop_thread = Event() + self.capture_thread = Thread(target=lambda: self.__read_loop(autosplit)) + self.capture_thread.start() def close(self, autosplit: AutoSplit): + self.stop_thread.set() + if self.capture_thread: + self.capture_thread.join() + self.capture_thread = None 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 - result, image = self.capture_device.read() - if not result: - return None, False + + image = self.last_captured_frame + is_old_image = self.is_old_image + self.is_old_image = True + if not is_valid_image(image): + return None, is_old_image + # 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) @@ -35,7 +66,10 @@ def get_frame(self, autosplit: AutoSplit): y:y + selection["height"], x:x + selection["width"], ] - return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA), False + return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA), is_old_image + + def recover_window(self, captured_window_title: str, autosplit: AutoSplit) -> bool: + raise NotImplementedError() def check_selected_region_exists(self, autosplit: AutoSplit): return bool(self.capture_device.isOpened()) diff --git a/src/error_messages.py b/src/error_messages.py index 1745d430..5e417893 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -6,13 +6,15 @@ import sys import traceback from types import TracebackType -from typing import Optional +from typing import TYPE_CHECKING, Optional from PyQt6 import QtCore, QtWidgets -from AutoSplit import AutoSplit from utils import FROZEN +if TYPE_CHECKING: + from AutoSplit import AutoSplit + def __exit_program(): # stop main thread (which is probably blocked reading input) via an interrupt signal diff --git a/src/hotkeys.py b/src/hotkeys.py index 154bc228..9048dcd6 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,7 +1,7 @@ from __future__ import annotations -import threading from collections.abc import Callable +from threading import Thread from typing import TYPE_CHECKING, Literal, Optional, Union import keyboard @@ -248,4 +248,4 @@ def callback(): # Try to remove the previously set hotkey if there is one. _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - threading.Thread(target=callback).start() + Thread(target=callback).start() diff --git a/src/menu_bar.py b/src/menu_bar.py index 28f3237c..9ddd113c 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -1,8 +1,8 @@ from __future__ import annotations import asyncio -import threading import webbrowser +from threading import Thread from typing import TYPE_CHECKING, Any, Union, cast import requests @@ -183,7 +183,7 @@ def __init__(self, autosplit: AutoSplit): # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() - threading.Thread(target=lambda: asyncio.run(self.__set_all_capture_devices())).start() + Thread(target=lambda: asyncio.run(self.__set_all_capture_devices())).start() capture_list_items = [ f"- {method.name} ({method.short_description})" for method in capture_method_values From b0a1c39db0c2828b2dcd7126a2473e4eeee8a4b6 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 13 Jul 2022 01:40:23 -0400 Subject: [PATCH 06/28] CI: Install wheel --- .github/workflows/lint-and-build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 8f4f48f7..bf127b3f 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -38,6 +38,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'scripts/requirements-dev.txt' + - run: pip install wheel --upgrade - name: Install dependencies run: | pip install -r "scripts/requirements-dev.txt" @@ -61,6 +62,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'scripts/requirements-dev.txt' + - run: pip install wheel --upgrade - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - run: scripts/compile_resources.ps1 @@ -81,6 +83,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'scripts/requirements-dev.txt' + - run: pip install wheel --upgrade - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - run: scripts/compile_resources.ps1 @@ -101,6 +104,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'scripts/requirements-dev.txt' + - run: pip install wheel --upgrade - name: Install dependencies run: pip install -r "scripts/requirements-dev.txt" - run: scripts/compile_resources.ps1 @@ -120,6 +124,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' + - run: pip install wheel --upgrade - name: Install dependencies run: pip install -r "scripts/requirements.txt" - run: scripts/build.ps1 From 96d707ac1b64dc681aeb34a5cd8b48a4424b4218 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 13 Jul 2022 20:28:35 -0400 Subject: [PATCH 07/28] Some UI fixes --- README.md | 4 +- res/about.ui | 11 ++-- res/design.ui | 10 +-- res/settings.ui | 65 +++++++------------ src/capture_method/BitBltCaptureMethod.py | 3 - .../VideoCaptureDeviceCaptureMethod.py | 1 + src/capture_method/__init__.py | 2 +- typings/cv2-stubs/__init__.pyi | 2 +- 8 files changed, 37 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 618fc23d..f2d8782d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ This program can be used to automatically start, split, and reset your preferred #### 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. + - 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. - 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. @@ -92,7 +92,7 @@ This program can be used to automatically start, split, and reset your preferred - **Force Full Content Rendering** (very slow, can affect rendering pipeline) 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. -- **Video Capture Device** (very slow, see below) +- **Video Capture Device** Uses a Video Capture Device, like a webcam, virtual cam, or capture card. There are currently performance issues, but it might be more convenient. If you want to use this with OBS' Virtual Camera, use the [Virtualcam plugin](https://obsproject.com/forum/resources/obs-virtualcam.949/) instead. diff --git a/res/about.ui b/res/about.ui index 4cb2cc3d..d128ab6b 100644 --- a/res/about.ui +++ b/res/about.ui @@ -52,7 +52,7 @@ 10 44 - 161 + 171 32 @@ -112,10 +112,10 @@ consider donating. Thank you! - 190 + 181 17 - 62 - 71 + 64 + 64 @@ -124,6 +124,9 @@ consider donating. Thank you! :/resources/icon.ico + + true + diff --git a/res/design.ui b/res/design.ui index b4f07175..5a00ad65 100644 --- a/res/design.ui +++ b/res/design.ui @@ -40,12 +40,6 @@ :/resources/icon.ico:/resources/icon.ico - - - - - Qt::LeftToRight - @@ -816,7 +810,7 @@ 449 344 - 98 + 101 16 @@ -827,7 +821,7 @@ - 550 + 560 344 98 16 diff --git a/res/settings.ui b/res/settings.ui index 51cf6b93..6a5d299d 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -28,12 +28,18 @@ 621 - - ArrowCursor + + + 9 + Settings + + + :/resources/icon.ico:/resources/icon.ico + false @@ -61,9 +67,6 @@ 22 - - ArrowCursor - QAbstractSpinBox::CorrectToNearestValue @@ -89,9 +92,6 @@ This value will limit the amount of frames per second that AutoSplit will run comparisons - - - Comparison FPS Limit: @@ -202,10 +202,21 @@ - - - - + 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. + +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. @@ -245,9 +256,6 @@ 16 - - - Default Pause Time (sec): @@ -289,12 +297,6 @@ 16 - - - - - - Default Similarity Threshold: @@ -361,9 +363,6 @@ true - - teset - <html><head/><body><p>Custom image settings and flags are set in the <br></br> image file name. These will override the default <br></br> values. View the <a href="https://github.com/Toufool/Auto-Split#readme"><span style=" text-decoration: underline; color:#0000ff;">README</span></a> for full details on all <br></br> available custom image settings.</p></body></html> @@ -377,12 +376,6 @@ 16 - - - - - - Default Delay Time (ms): @@ -396,9 +389,6 @@ 22 - - ArrowCursor - After an image is matched, this is the amount of time in millseconds that will be delayed before splitting. @@ -475,9 +465,6 @@ 20 - - Qt::StrongFocus - @@ -507,9 +494,6 @@ 20 - - IBeamCursor - @@ -600,9 +584,6 @@ 20 - - Qt::StrongFocus - diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index b292e9e1..04e94123 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -24,9 +24,6 @@ class BitBltCaptureMethod(CaptureMethodInterface): _render_full_content = False - def close(self, autosplit: AutoSplit): - pass - def get_frame(self, autosplit: AutoSplit): selection = autosplit.settings_dict["capture_region"] hwnd = autosplit.hwnd diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 9c197232..fb737119 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -28,6 +28,7 @@ def __read_loop(self, autosplit: AutoSplit): self.is_old_image = False except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here error = exception + self.capture_device.release() autosplit.show_error_signal.emit(lambda: exception_traceback( "AutoSplit encountered an unhandled exception while trying to grab a frame and has stopped capture. " + CREATE_NEW_ISSUE_MESSAGE, diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 3b74eb99..69a57104 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -129,7 +129,7 @@ def get_method_by_index(self, index: int): ), CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: CaptureMethodInfo( name="Video Capture Device", - short_description="very slow, see below", + short_description="see below", description=( "\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. " "\nYou can select one below. " diff --git a/typings/cv2-stubs/__init__.pyi b/typings/cv2-stubs/__init__.pyi index 550c3001..1c1af721 100644 --- a/typings/cv2-stubs/__init__.pyi +++ b/typings/cv2-stubs/__init__.pyi @@ -1579,7 +1579,7 @@ def VariationalRefinement_create() -> typing.Any: class VideoCapture(): name: str device_id: int - def __init__(self, device_id: int, backend: int | None = ...): ... + def __init__(self, device_id: int | str, backend: int | None = ...): ... def set(self, property: int, value: int): ... def get(self, property: int) -> int: ... def grab(self) -> bool: ... From 38930a47dcbed9778e3a6bbae41ec19a41928b14 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 13 Jul 2022 22:35:48 -0400 Subject: [PATCH 08/28] Show a warning if AutoSplit is already open Closes #150 --- scripts/requirements.txt | 1 + src/AutoSplit.py | 18 ++++++++++++++++++ src/error_messages.py | 21 +++++++++++++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 154105c9..a6dfc4fe 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -22,6 +22,7 @@ pyautogui requests certifi toml +psutil pygrabber typing-extensions # Windows-only diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 339292cd..e328dcc6 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -13,6 +13,7 @@ import certifi import cv2 +from psutil import process_iter from PyQt6 import QtCore, QtGui from PyQt6.QtTest import QTest from PyQt6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget @@ -888,11 +889,28 @@ def seconds_remaining_text(seconds: float): return f"{seconds:.1f} second{'' if 0 < seconds <= 1 else 's'} remaining" +def is_already_running(): + # 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 + process_count = 0 + for process in process_iter(): + if process.name() == "AutoSplit.exe": + process_count += 1 + if process_count >= max_processes: + return True + return False + + def main(): # Call to QApplication outside the try-except so we can show error messages app = QApplication(sys.argv) try: app.setWindowIcon(QtGui.QIcon(":/resources/icon.ico")) + + if is_already_running(): + error_messages.already_running() + AutoSplit() if not FROZEN: diff --git a/src/error_messages.py b/src/error_messages.py index 5e417893..edd4ca86 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -22,14 +22,18 @@ def __exit_program(): sys.exit(1) -def set_text_message(message: str, details: 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) message_box.setText(message) - if details: - force_quit_button = message_box.addButton("Close AutoSplit", QtWidgets.QMessageBox.ButtonRole.ResetRole) + # 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(): @@ -121,10 +125,19 @@ def stdin_lost(): set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") +def already_running(): + 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(message: str, exception: BaseException): set_text_message( message, - "\n".join(traceback.format_exception(None, exception, exception.__traceback__))) + "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), + "Close AutoSplit") CREATE_NEW_ISSUE_MESSAGE = ( From 9fe46c4b8c67c95b7e582e5e86eafa3cb952dc2d Mon Sep 17 00:00:00 2001 From: Avasam Date: Thu, 14 Jul 2022 19:15:01 -0400 Subject: [PATCH 09/28] Fixed linting --- pyproject.toml | 7 ++++++- scripts/requirements-dev.txt | 2 +- src/capture_method/BitBltCaptureMethod.py | 9 ++++----- src/capture_method/__init__.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48643933..1282956d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,12 @@ disable = [ ] [tool.pylint.TYPECHECK] -generated-members = "cv2" +generated-members = [ + # https://github.com/PyCQA/pylint/issues/4987 + "cv2", + # https://github.com/mhammond/pywin32/issues/1913 + "pywintypes.error", +] [tool.isort] line_length = 120 diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 9249c181..b0c84c99 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -9,7 +9,7 @@ flake8-pyi flake8-quotes flake8-isort pylint>=2.13.9 -pywin32-stubs>=0.1.6 +git+https://github.com/Avasam/pywin32-stubs.git#egg=pywin32-stubs # https://github.com/kaluluosi/pywin32-stubs/pull/8 types-requests # # You can comment this out if you don't want to run `designer.bat` to quickly open the bundled PyQt Designer. diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 04e94123..e3893000 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -55,17 +55,16 @@ def get_frame(self, autosplit: AutoSplit): win32con.SRCCOPY) image = np.frombuffer(cast(bytes, bitmap.GetBitmapBits(True)), dtype=np.uint8) image.shape = (selection["height"], selection["width"], 4) - # https://github.com/kaluluosi/pywin32-stubs/issues/5 - except (win32ui.error, pywintypes.error): # pyright: ignore [reportGeneralTypeIssues] pylint: disable=no-member + except (win32ui.error, pywintypes.error): return None, False # We already obtained the image, so we can ignore errors during cleanup try: + dc_object.DeleteDC() dc_object.DeleteDC() compatible_dc.DeleteDC() win32gui.ReleaseDC(hwnd, window_dc) - win32gui.DeleteObject(bitmap.GetHandle()) # pyright: ignore [reportGeneralTypeIssues] - # https://github.com/kaluluosi/pywin32-stubs/issues/5 - except win32ui.error: # pyright: ignore [reportGeneralTypeIssues] + win32gui.DeleteObject(bitmap.GetHandle()) + except win32ui.error: pass return image, False diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 69a57104..5afc2ebe 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -180,7 +180,7 @@ class CameraInfo(): backend: str -async def get_all_video_capture_devices(): +async def get_all_video_capture_devices() -> list[CameraInfo]: named_video_inputs = FilterGraph().get_input_devices() async def get_camera_info(index: int, device_name: str): From d6f394f87134f398f2b0502466fb86809f6e8bb4 Mon Sep 17 00:00:00 2001 From: Avasam Date: Thu, 14 Jul 2022 19:47:22 -0400 Subject: [PATCH 10/28] Add version number --- scripts/compile_resources.ps1 | 4 ++++ src/utils.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/compile_resources.ps1 b/scripts/compile_resources.ps1 index 6495dd33..2c15beff 100755 --- a/scripts/compile_resources.ps1 +++ b/scripts/compile_resources.ps1 @@ -9,4 +9,8 @@ pyuic6 './res/update_checker.ui' -o './src/gen/update_checker.py' pyside6-rcc './res/resources.qrc' -o './src/gen/resources_rc.py' Write-Host 'Generated code from .ui files' +$BUILD_NUMBER = Get-Date -Format yyMMddHHMMss +New-Item "$PSScriptRoot/../src/gen/build_number.py" -ItemType File -Force -Value "AUTOSPLIT_BUILD_NUMBER = `"$BUILD_NUMBER`"" | Out-Null +Write-Host "Generated build number: `"$BUILD_NUMBER`"" + Set-Location $originalDirectory diff --git a/src/utils.py b/src/utils.py index ab40e1a3..d7a20bca 100644 --- a/src/utils.py +++ b/src/utils.py @@ -11,6 +11,8 @@ from typing_extensions import TypeGuard from win32 import win32gui +from gen.build_number import AUTOSPLIT_BUILD_NUMBER + DWMWA_EXTENDED_FRAME_BOUNDS = 9 @@ -67,5 +69,8 @@ def wrapped(*args: Any, **kwargs: Any): """The directory of either AutoSplit.exe or AutoSplit.py""" # Shared strings -AUTOSPLIT_VERSION = "2.0.0-alpha.4" +# DIRTY_VERSION_EXTENSION = "" +DIRTY_VERSION_EXTENSION = "-" + AUTOSPLIT_BUILD_NUMBER +"""Set DIRTY_VERSION_EXTENSION to an empty string to generate a clean version number""" +AUTOSPLIT_VERSION = "2.0.0-alpha.4" + DIRTY_VERSION_EXTENSION START_AUTO_SPLITTER_TEXT = "Start Auto Splitter" From 92a9541bc9f2aa85de6161bcf1f4341d3c695cc4 Mon Sep 17 00:00:00 2001 From: Avasam Date: Thu, 14 Jul 2022 20:53:35 -0400 Subject: [PATCH 11/28] Generate hotkey attributes in AutoSplit --- src/AutoSplit.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 19e999fa..07705163 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -68,14 +68,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): CheckForUpdatesThread: Optional[QtCore.QThread] = None SettingsWidget: Optional[settings.Ui_DialogSettings] = None - # hotkeys need to be initialized to be passed as thread arguments in hotkeys.py - # and for type safety in both hotkeys.py and settings_file.py - split_hotkey: Optional[Callable[[], None]] = None - reset_hotkey: Optional[Callable[[], None]] = None - skip_split_hotkey: Optional[Callable[[], None]] = None - undo_split_hotkey: Optional[Callable[[], None]] = None - pause_hotkey: Optional[Callable[[], None]] = None - # Initialize a few attributes hwnd = 0 """Window Handle used for Capture Region""" @@ -124,6 +116,10 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man self.width_spinbox.setFrame(False) self.height_spinbox.setFrame(False) + # hotkeys need to be initialized to be passed as thread arguments in hotkeys.py + for hotkey in HOTKEYS: + setattr(self, f"{hotkey}_hotkey", None) + # Get default values defined in SettingsDialog self.settings_dict = get_default_settings_from_ui(self) user_profile.load_check_for_updates_on_open(self) From cc76c91f63debf7b345b4b89eee00de4137fb3b1 Mon Sep 17 00:00:00 2001 From: Avasam Date: Thu, 14 Jul 2022 20:54:15 -0400 Subject: [PATCH 12/28] Added hotkey for disable auto reset image --- res/settings.ui | 52 ++++++++++++++++++++++++++++++++++++++++++--- src/AutoSplit.py | 1 - src/hotkeys.py | 7 ++++-- src/menu_bar.py | 1 + src/user_profile.py | 2 ++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/res/settings.ui b/res/settings.ui index 6a5d299d..57c2f5cd 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -50,7 +50,7 @@ 10 - 180 + 200 271 181 @@ -175,7 +175,7 @@ 10 - 370 + 390 271 241 @@ -406,7 +406,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 10 10 271 - 161 + 181 @@ -649,6 +649,52 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i true
+ + + + 180 + 155 + 81 + 21 + + + + Qt::NoFocus + + + Set Hotkey + + + + + + 6 + 150 + 71 + 31 + + + + Disable auto +reset image + + + + + + 76 + 155 + 94 + 20 + + + + + + + true + +
diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 07705163..15b9fbd1 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -6,7 +6,6 @@ import os import signal import sys -from collections.abc import Callable from time import time from types import FunctionType from typing import Optional diff --git a/src/hotkeys.py b/src/hotkeys.py index 9048dcd6..e8045009 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -20,8 +20,8 @@ Commands = Literal["split", "start", "pause", "reset", "skip", "undo"] -Hotkeys = Literal["split", "reset", "skip_split", "undo_split", "pause"] -HOTKEYS: list[Hotkeys] = ["split", "reset", "skip_split", "undo_split", "pause"] +Hotkeys = Literal["split", "reset", "skip_split", "undo_split", "pause", "disable_auto_reset_image"] +HOTKEYS: list[Hotkeys] = ["split", "reset", "skip_split", "undo_split", "pause", "disable_auto_reset_image"] def before_setting_hotkey(autosplit: AutoSplit): @@ -202,6 +202,9 @@ def __get_hotkey_action(autosplit: AutoSplit, hotkey: Hotkeys): return lambda: autosplit.skip_split(True) if hotkey == "undo_split": return lambda: autosplit.undo_split(True) + if hotkey == "disable_auto_reset_image": + return lambda: autosplit.disable_auto_reset_checkbox.setChecked( + not autosplit.disable_auto_reset_checkbox.isChecked()) return getattr(autosplit, f"{hotkey}_signal").emit # TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to diff --git a/src/menu_bar.py b/src/menu_bar.py index 9ddd113c..a7af39ed 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -279,6 +279,7 @@ def get_default_settings_from_ui(autosplit: AutoSplit): "undo_split_hotkey": default_settings_dialog.undo_split_input.text(), "skip_split_hotkey": default_settings_dialog.skip_split_input.text(), "pause_hotkey": default_settings_dialog.pause_input.text(), + "disable_auto_reset_image_hotkey": default_settings_dialog.disable_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( diff --git a/src/user_profile.py b/src/user_profile.py index ff7df78b..d1646434 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -23,6 +23,7 @@ class UserProfileDict(TypedDict): undo_split_hotkey: str skip_split_hotkey: str pause_hotkey: str + disable_auto_reset_image_hotkey: str fps_limit: int live_capture_region: bool capture_method: Union[str, CaptureMethodEnum] @@ -45,6 +46,7 @@ class UserProfileDict(TypedDict): undo_split_hotkey="", skip_split_hotkey="", pause_hotkey="", + disable_auto_reset_image_hotkey="", fps_limit=60, live_capture_region=True, capture_method=CAPTURE_METHODS.get_method_by_index(0), From 57a9c80fa3f9d4973a93d81fe2b813eaf3220393 Mon Sep 17 00:00:00 2001 From: Avasam Date: Thu, 14 Jul 2022 21:12:18 -0400 Subject: [PATCH 13/28] Updated doc for per-image comparison method --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f2d8782d..1f313c2f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=security_rating)](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Avasam_Auto-Split&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split) -[![SemVer](https://badgen.net/badge/SemVer/SemVer/grey?label)](https://semver.org/) +[![SemVer](https://badgen.net/badge/_/SemVer%20compliant/grey?label)](https://semver.org/) Easy to use image comparison based auto splitter for speedrunning on console or PC. @@ -157,11 +157,15 @@ If this option is disabled, when the reset hotkey is hit, the reset button is pr - 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 angular brackets `<>`: + - `<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. + - `{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. From 723bddae184bf72125cbd102d6da99ac0b01f2a9 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 16 Jul 2022 15:16:02 -0400 Subject: [PATCH 14/28] Added workflow_dispatch for manual triggers --- .github/workflows/lint-and-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index bf127b3f..87cad2e8 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,6 +1,7 @@ # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions name: Lint and build on: + workflow_dispatch: push: branches: - main From c2b13244830d5c3d773acffcf557a5f2a1edab6c Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 22 Jul 2022 16:29:38 -0400 Subject: [PATCH 15/28] update comparison_method_from_filename symbol --- README.md | 8 ++++---- src/split_parser.py | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1f313c2f..3aa353a1 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,10 @@ If this option is disabled, when the reset hotkey is hit, the reset button is pr - 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 angular brackets `<>`: - - `<0>`: L2 Norm - - `<1>`: Histogram - - `<2>`: Perceptual Hash +- 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. diff --git a/src/split_parser.py b/src/split_parser.py index e98a1eb6..854f892f 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -17,6 +17,9 @@ T = TypeVar("T", str, int, float) +# Note, the following symbols cannot be used in a filename: +# / \ : * ? " < > | + def __value_from_filename( filename: str, @@ -111,7 +114,7 @@ def comparison_method_from_filename(filename: str): # Check to make sure there is a valid delay time between brackets # of the filename - value = __value_from_filename(filename, "<>", -1) + value = __value_from_filename(filename, "^^", -1) # Comparison method should always be positive or zero return value if value >= 0 else None From 3894896412b39c5df5e1ea99929ee6449009a7c8 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 16 Jul 2022 10:04:56 -0400 Subject: [PATCH 16/28] Extract hwnd validation --- src/capture_method/BitBltCaptureMethod.py | 8 ++----- .../DesktopDuplicationCaptureMethod.py | 2 +- .../WindowsGraphicsCaptureMethod.py | 8 ++----- src/capture_method/interface.py | 4 +++- src/region_selection.py | 8 +++---- src/utils.py | 23 ++++++++++++++++--- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index e3893000..f608952f 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -12,7 +12,7 @@ from win32 import win32gui from capture_method.interface import CaptureMethodInterface -from utils import get_window_bounds +from utils import get_window_bounds, is_valid_hwnd if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -70,11 +70,7 @@ def get_frame(self, autosplit: AutoSplit): def recover_window(self, captured_window_title: str, autosplit: AutoSplit): hwnd = win32gui.FindWindow(None, captured_window_title) - # Don't fallback to desktop or whatever window obtained with "" - if not win32gui.IsWindow(hwnd) or not captured_window_title: + if not is_valid_hwnd(hwnd): return False autosplit.hwnd = hwnd return self.check_selected_region_exists(autosplit) - - def check_selected_region_exists(self, autosplit: AutoSplit): - return bool(win32gui.IsWindow(autosplit.hwnd) and win32gui.GetWindowText(autosplit.hwnd)) diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index fd216d3e..6a9234e8 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -23,7 +23,7 @@ def get_frame(self, autosplit: AutoSplit): selection = autosplit.settings_dict["capture_region"] hwnd = autosplit.hwnd hmonitor = ctypes.windll.user32.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) - if not hmonitor or not win32gui.IsWindow(hwnd): + if not hmonitor or not self.check_selected_region_exists(autosplit): return None, False left_bounds, top_bounds, *_ = get_window_bounds(hwnd) diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 5e9bbe9e..59cdb4d6 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -14,7 +14,7 @@ from winsdk.windows.media.capture import MediaCapture from capture_method.interface import CaptureMethodInterface -from utils import WINDOWS_BUILD_NUMBER +from utils import WINDOWS_BUILD_NUMBER, is_valid_hwnd if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -121,8 +121,7 @@ async def coroutine(): def recover_window(self, captured_window_title: str, autosplit: AutoSplit): hwnd = win32gui.FindWindow(None, captured_window_title) - # Don't fallback to desktop or whatever window obtained with "" - if not win32gui.IsWindow(hwnd) or not captured_window_title: + if not is_valid_hwnd(hwnd): return False autosplit.hwnd = hwnd self.close(autosplit) @@ -134,6 +133,3 @@ def recover_window(self, captured_window_title: str, autosplit: AutoSplit): return False raise return self.check_selected_region_exists(autosplit) - - def check_selected_region_exists(self, autosplit: AutoSplit): - return bool(win32gui.IsWindow(autosplit.hwnd) and win32gui.GetWindowText(autosplit.hwnd)) diff --git a/src/capture_method/interface.py b/src/capture_method/interface.py index 24804129..b10e02f2 100644 --- a/src/capture_method/interface.py +++ b/src/capture_method/interface.py @@ -4,6 +4,8 @@ import cv2 +from utils import is_valid_hwnd + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -32,4 +34,4 @@ def recover_window(self, captured_window_title: str, autosplit: AutoSplit) -> bo raise NotImplementedError() def check_selected_region_exists(self, autosplit: AutoSplit) -> bool: - raise NotImplementedError() + return is_valid_hwnd(autosplit.hwnd) diff --git a/src/region_selection.py b/src/region_selection.py index cf9809f1..b8d62a65 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -17,7 +17,7 @@ from winsdk.windows.graphics.capture import GraphicsCaptureItem, GraphicsCapturePicker import error_messages -from utils import get_window_bounds, is_valid_image +from utils import get_window_bounds, is_valid_hwnd, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -90,8 +90,7 @@ def select_region(autosplit: AutoSplit): del selector hwnd, window_text = __get_window_from_point(x, y) - # Don't select desktop - if not win32gui.IsWindow(hwnd) or not window_text: + if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() return @@ -123,8 +122,7 @@ def select_window(autosplit: AutoSplit): del selector hwnd, window_text = __get_window_from_point(x, y) - # Don't select desktop - if not win32gui.IsWindow(hwnd) or not window_text: + if not is_valid_hwnd(hwnd) or not window_text: error_messages.region() return diff --git a/src/utils.py b/src/utils.py index d7a20bca..3740ea28 100644 --- a/src/utils.py +++ b/src/utils.py @@ -3,9 +3,9 @@ import ctypes.wintypes import os import sys -from collections.abc import Callable +from collections.abc import Callable, Iterable from platform import version -from typing import Any, Optional, Union, cast +from typing import Any, Optional, TypeVar, Union, cast import cv2 from typing_extensions import TypeGuard @@ -36,7 +36,24 @@ def is_valid_image(image: Optional[cv2.Mat]) -> TypeGuard[cv2.Mat]: return image is not None and bool(image.size) -def get_window_bounds(hwnd: int): +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 + if sys.platform == "win32" and not (win32gui.IsWindow(hwnd) and win32gui.GetWindowText(hwnd)): + return False + return True + + +T = TypeVar("T") + + +def first(iterable: Iterable[T]) -> T: + """@return: The first element of a collection. Dictionaries will return the first key""" + return next(iter(iterable)) + + +def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: extended_frame_bounds = ctypes.wintypes.RECT() ctypes.windll.dwmapi.DwmGetWindowAttribute( hwnd, From 6a1349a58c6a7724dc353b8809377a5fbe224a25 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 22 Jul 2022 17:58:26 -0400 Subject: [PATCH 17/28] Fixed window recovery from loading profile and WGC --- .gitignore | 3 +++ scripts/compile_resources.ps1 | 2 +- src/capture_method/BitBltCaptureMethod.py | 2 +- src/capture_method/WindowsGraphicsCaptureMethod.py | 14 ++++++++++++-- src/user_profile.py | 6 ++---- src/utils.py | 4 ++-- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 29db603f..84c492a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Caches +.cache/ + # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/scripts/compile_resources.ps1 b/scripts/compile_resources.ps1 index 2c15beff..a5584068 100755 --- a/scripts/compile_resources.ps1 +++ b/scripts/compile_resources.ps1 @@ -9,7 +9,7 @@ pyuic6 './res/update_checker.ui' -o './src/gen/update_checker.py' pyside6-rcc './res/resources.qrc' -o './src/gen/resources_rc.py' Write-Host 'Generated code from .ui files' -$BUILD_NUMBER = Get-Date -Format yyMMddHHMMss +$BUILD_NUMBER = Get-Date -Format yyMMddHHMM New-Item "$PSScriptRoot/../src/gen/build_number.py" -ItemType File -Force -Value "AUTOSPLIT_BUILD_NUMBER = `"$BUILD_NUMBER`"" | Out-Null Write-Host "Generated build number: `"$BUILD_NUMBER`"" diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index f608952f..f594ca3b 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -24,7 +24,7 @@ class BitBltCaptureMethod(CaptureMethodInterface): _render_full_content = False - def get_frame(self, autosplit: AutoSplit): + def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: selection = autosplit.settings_dict["capture_region"] hwnd = autosplit.hwnd image: Optional[cv2.Mat] = None diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 59cdb4d6..a430a117 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -31,7 +31,7 @@ class WindowsGraphicsCaptureMethod(CaptureMethodInterface): def __init__(self, autosplit: AutoSplit): super().__init__(autosplit) - if not self.check_selected_region_exists(autosplit): + if not is_valid_hwnd(autosplit.hwnd): return # 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 @@ -81,7 +81,9 @@ def close(self, autosplit: AutoSplit): def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: selection = autosplit.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(autosplit) or not self.frame_pool or not self.session: + if not (self.check_selected_region_exists(autosplit) + # Only needed for the type-checker + and self.frame_pool): return None, False try: @@ -89,6 +91,8 @@ def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: # Frame pool is closed except OSError: return None, False + + # We were too fast and the next frame wasn't ready yet if not frame: return self.last_captured_frame, True @@ -133,3 +137,9 @@ def recover_window(self, captured_window_title: str, autosplit: AutoSplit): return False raise return self.check_selected_region_exists(autosplit) + + def check_selected_region_exists(self, autosplit: AutoSplit): + return bool( + is_valid_hwnd(autosplit.hwnd) + and self.frame_pool + and self.session) diff --git a/src/user_profile.py b/src/user_profile.py index ff7df78b..77ea79be 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -134,10 +134,8 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str set_hotkey(autosplit, hotkey, cast(str, autosplit.settings_dict[hotkey_name])) change_capture_method(cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit) - if ( - not autosplit.capture_method.check_selected_region_exists(autosplit) - and autosplit.settings_dict["captured_window_title"] - ): + autosplit.capture_method.recover_window(autosplit.settings_dict["captured_window_title"], autosplit) + if not autosplit.capture_method.check_selected_region_exists(autosplit): autosplit.live_image.setText("Reload settings after opening" + f'\n"{autosplit.settings_dict["captured_window_title"]}"' + "\nto automatically load Capture Region") diff --git a/src/utils.py b/src/utils.py index 3740ea28..de07952c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -40,8 +40,8 @@ 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 - if sys.platform == "win32" and not (win32gui.IsWindow(hwnd) and win32gui.GetWindowText(hwnd)): - return False + if sys.platform == "win32": + return bool(win32gui.IsWindow(hwnd) and win32gui.GetWindowText(hwnd)) return True From 7410a9fbbaa9a56ef4f7ddb88789a6a67e6917b3 Mon Sep 17 00:00:00 2001 From: Avasam Date: Fri, 22 Jul 2022 21:46:21 -0400 Subject: [PATCH 18/28] Updated build scripts --- .github/workflows/lint-and-build.yml | 63 +++++++++++++--------------- scripts/build.ps1 | 8 +--- scripts/compile_resources.ps1 | 2 +- scripts/install.ps1 | 21 ++++++---- 4 files changed, 46 insertions(+), 48 deletions(-) diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 87cad2e8..2511e920 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,7 +1,7 @@ # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions name: Lint and build on: - workflow_dispatch: + workflow_dispatch: # Allows manual builds push: branches: - main @@ -38,14 +38,9 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: 'scripts/requirements-dev.txt' - - run: pip install wheel --upgrade - - name: Install dependencies - run: | - pip install -r "scripts/requirements-dev.txt" - npm install -g pyright - npm list -g pyright - - run: scripts/compile_resources.ps1 + cache-dependency-path: 'scripts/requirements*.txt' + - run: scripts/install.ps1 + shell: pwsh - name: Analysing the code with Pyright run: pyright --warnings Pylint: @@ -62,11 +57,9 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: 'scripts/requirements-dev.txt' - - run: pip install wheel --upgrade - - name: Install dependencies - run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.ps1 + cache-dependency-path: 'scripts/requirements*.txt' + - run: scripts/install.ps1 + shell: pwsh - name: Analysing the code with Pylint run: pylint --reports=y --output-format=colorized src/ Flake8: @@ -83,32 +76,26 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: 'scripts/requirements-dev.txt' - - run: pip install wheel --upgrade - - name: Install dependencies - run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.ps1 + cache-dependency-path: 'scripts/requirements*.txt' + - run: scripts/install.ps1 + shell: pwsh - name: Analysing the code with Flake8 run: flake8 Bandit: runs-on: windows-latest strategy: fail-fast: false - matrix: - python-version: ["3.9", "3.10"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.10 uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.10" cache: 'pip' - cache-dependency-path: 'scripts/requirements-dev.txt' - - run: pip install wheel --upgrade - - name: Install dependencies - run: pip install -r "scripts/requirements-dev.txt" - - run: scripts/compile_resources.ps1 + cache-dependency-path: 'scripts/requirements*.txt' + - run: scripts/install.ps1 + shell: pwsh - name: Analysing the code with Bandit run: bandit -n 1 --severity-level medium --recursive src Build: @@ -116,7 +103,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10"] + python-version: ["3.10"] steps: - name: Checkout ${{ github.repository }}/${{ github.ref }} uses: actions/checkout@v3 @@ -125,12 +112,22 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - - run: pip install wheel --upgrade - - name: Install dependencies - run: pip install -r "scripts/requirements.txt" + - run: scripts/install.ps1 + shell: pwsh - run: scripts/build.ps1 + shell: pwsh - name: Upload Build Artifact uses: actions/upload-artifact@v3 with: name: AutoSplit (Python ${{ matrix.python-version }}) - path: dist/AutoSplit.exe + 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 }}) + path: | + build/AutoSplit/*.toc + build/AutoSplit/*.txt + build/AutoSplit/*.html + if-no-files-found: error diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 59b22626..b9b3e387 100755 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,4 +1,5 @@ & "$PSScriptRoot/compile_resources.ps1" + pyinstaller ` --windowed ` --onefile ` @@ -6,10 +7,3 @@ pyinstaller ` --icon=res/icon.ico ` --splash=res/splash.png ` "$PSScriptRoot/../src/AutoSplit.py" - -If ($IsLinux) { - Move-Item $PSScriptRoot/../dist/AutoSplit $PSScriptRoot/../dist/AutoSplit.elf - If ($LastExitCode -eq 0) { - Write-Host 'Added .elf extension' - } -} diff --git a/scripts/compile_resources.ps1 b/scripts/compile_resources.ps1 index a5584068..a2369324 100755 --- a/scripts/compile_resources.ps1 +++ b/scripts/compile_resources.ps1 @@ -9,7 +9,7 @@ pyuic6 './res/update_checker.ui' -o './src/gen/update_checker.py' pyside6-rcc './res/resources.qrc' -o './src/gen/resources_rc.py' Write-Host 'Generated code from .ui files' -$BUILD_NUMBER = Get-Date -Format yyMMddHHMM +$BUILD_NUMBER = Get-Date -Format yyMMddHHmm New-Item "$PSScriptRoot/../src/gen/build_number.py" -ItemType File -Force -Value "AUTOSPLIT_BUILD_NUMBER = `"$BUILD_NUMBER`"" | Out-Null Write-Host "Generated build number: `"$BUILD_NUMBER`"" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e741c3d5..96fa9e73 100755 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,9 +1,16 @@ -If ($IsLinux) { - sudo apt-get install python3-tk +# Installing Python dependencies +$dev = If ($env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } +pip install wheel --upgrade +pip install -r "$PSScriptRoot/requirements$dev-win32.txt" + +# Don't compile resources on the Build CI job as it'll do so in build script +If ($dev) { + Write-Host "`n" + & "$PSScriptRoot/compile_resources.ps1" } -python -m pip install wheel --upgrade -python -m pip install -r "$PSScriptRoot/requirements-dev.txt" -& "$PSScriptRoot/compile_resources.ps1" -npm install -g pyright@latest -npm list -g pyright +# Only the Pyright job and local devs have node installed +if (-not $env:GITHUB_JOB -or $env:GITHUB_JOB -eq 'Pyright') { + npm install --location=global pyright@latest + npm list --location=global pyright +} From 0aed849be97a43b8ce722603cad9905654a1caa0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 23 Jul 2022 02:19:52 -0400 Subject: [PATCH 19/28] Fixed crashes when opening from LiveSplit - NotImplementedError from the CaptureInterface - and OutOfRange errors from CaptureMethodDict --- scripts/install.ps1 | 2 +- src/capture_method/__init__.py | 25 +++++++++++++++++++++++++ src/capture_method/interface.py | 4 ++-- src/menu_bar.py | 2 +- src/user_profile.py | 12 ++++++++---- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 96fa9e73..bf6123f8 100755 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,7 +1,7 @@ # Installing Python dependencies $dev = If ($env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' } pip install wheel --upgrade -pip install -r "$PSScriptRoot/requirements$dev-win32.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/src/capture_method/__init__.py b/src/capture_method/__init__.py index 5afc2ebe..9179d9e5 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -66,6 +66,7 @@ def __eq__(self, other: object): def __hash__(self): return self.value.__hash__() + NONE = "" BITBLT = "BITBLT" WINDOWS_GRAPHICS_CAPTURE = "WINDOWS_GRAPHICS_CAPTURE" PRINTWINDOW_RENDERFULLCONTENT = "PRINTWINDOW_RENDERFULLCONTENT" @@ -74,11 +75,35 @@ def __hash__(self): class CaptureMethodDict(OrderedDict[CaptureMethodEnum, CaptureMethodInfo]): + def get_method_by_index(self, index: int): + if len(self) <= 0: + return CaptureMethodEnum.NONE if index < 0: return next(iter(self)) return list(self.keys())[index] + def __getitem__(self, key: CaptureMethodEnum): + if key == CaptureMethodEnum.NONE: + 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 + + +NONE_CAPTURE_METHOD = CaptureMethodInfo( + name="None", + short_description="", + description="", + implementation=CaptureMethodInterface +) CAPTURE_METHODS = CaptureMethodDict({ CaptureMethodEnum.BITBLT: CaptureMethodInfo( diff --git a/src/capture_method/interface.py b/src/capture_method/interface.py index b10e02f2..d1646fd2 100644 --- a/src/capture_method/interface.py +++ b/src/capture_method/interface.py @@ -28,10 +28,10 @@ def get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]: @return: The image of the region in the window in BGRA format """ - raise NotImplementedError() + return None, False def recover_window(self, captured_window_title: str, autosplit: AutoSplit) -> bool: - raise NotImplementedError() + return False def check_selected_region_exists(self, autosplit: AutoSplit) -> bool: return is_valid_hwnd(autosplit.hwnd) diff --git a/src/menu_bar.py b/src/menu_bar.py index 9ddd113c..adff9fea 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -194,7 +194,7 @@ def __init__(self, autosplit: AutoSplit): # Assuming all options take 2 lines (except camera and BitBlt which have 1). # And all lines take 16 pixels # And all separators take 2 pixels - doubled_len = 2 * len(capture_method_values) + doubled_len = 2 * len(capture_method_values) or 2 list_view.setMinimumHeight((doubled_len - 2) * 16 + doubled_len) list_view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.capture_method_combobox.setView(list_view) diff --git a/src/user_profile.py b/src/user_profile.py index 77ea79be..84a0889f 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -163,12 +163,16 @@ def load_settings_on_open(autosplit: AutoSplit): if file.endswith(".toml")] # Find all .tomls in AutoSplit folder, error if there is not exactly 1 + error = None if len(settings_files) < 1: - error_messages.no_settings_file_on_open() - return - if len(settings_files) > 1: - error_messages.too_many_settings_files_on_open() + error = error_messages.no_settings_file_on_open + elif len(settings_files) > 1: + error = error_messages.too_many_settings_files_on_open + if error: + change_capture_method(CAPTURE_METHODS.get_method_by_index(0), autosplit) + error() return + load_settings(autosplit, os.path.join(auto_split_directory, settings_files[0])) From 09b5c81cd031fc98e7db55d24ceb81be9c5ef770 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 23 Jul 2022 19:56:16 -0400 Subject: [PATCH 20/28] Completed cv2 error type stubs --- typings/cv2-stubs/Error.pyi | 110 +++++++++++++++++++++++++++++++++ typings/cv2-stubs/__init__.pyi | 12 +++- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 typings/cv2-stubs/Error.pyi diff --git a/typings/cv2-stubs/Error.pyi b/typings/cv2-stubs/Error.pyi new file mode 100644 index 00000000..f3cc4e03 --- /dev/null +++ b/typings/cv2-stubs/Error.pyi @@ -0,0 +1,110 @@ +BadAlign = -21 +BAD_ALIGN = -21 +BadAlphaChannel = -18 +BAD_ALPHA_CHANNEL = -18 +BadCOI = -24 +BAD_COI = -24 +BadCallBack = -22 +BAD_CALL_BACK = -22 +BadDataPtr = -12 +BAD_DATA_PTR = -12 +BadDepth = -17 +BAD_DEPTH = -17 +BadImageSize = -10 +BAD_IMAGE_SIZE = -10 +BadModelOrChSeq = -14 +BAD_MODEL_OR_CH_SEQ = -14 +BadNumChannel1U = -16 +BAD_NUM_CHANNEL1U = -16 +BadNumChannels = -15 +BAD_NUM_CHANNELS = -15 +BadOffset = -11 +BAD_OFFSET = -11 +BadOrder = -19 +BAD_ORDER = -19 +BadOrigin = -20 +BAD_ORIGIN = -20 +BadROISize = -25 +BAD_ROISIZE = -25 +BadStep = -13 +BAD_STEP = -13 +BadTileSize = -23 +BAD_TILE_SIZE = -23 +GpuApiCallError = -217 +GPU_API_CALL_ERROR = -217 +GpuNotSupported = -216 +GPU_NOT_SUPPORTED = -216 +HeaderIsNull = -9 +HEADER_IS_NULL = -9 +MaskIsTiled = -26 +MASK_IS_TILED = -26 +OpenCLApiCallError = -220 +OPEN_CLAPI_CALL_ERROR = -220 +OpenCLDoubleNotSupported = -221 +OPEN_CLDOUBLE_NOT_SUPPORTED = -221 +OpenCLInitError = -222 +OPEN_CLINIT_ERROR = -222 +OpenCLNoAMDBlasFft = -223 +OPEN_CLNO_AMDBLAS_FFT = -223 +OpenGlApiCallError = -219 +OPEN_GL_API_CALL_ERROR = -219 +OpenGlNotSupported = -218 +OPEN_GL_NOT_SUPPORTED = -218 +StsAssert = -215 +STS_ASSERT = -215 +StsAutoTrace = -8 +STS_AUTO_TRACE = -8 +StsBackTrace = -1 +STS_BACK_TRACE = -1 +StsBadArg = -5 +STS_BAD_ARG = -5 +StsBadFlag = -206 +STS_BAD_FLAG = -206 +StsBadFunc = -6 +STS_BAD_FUNC = -6 +StsBadMask = -208 +STS_BAD_MASK = -208 +StsBadMemBlock = -214 +STS_BAD_MEM_BLOCK = -214 +StsBadPoint = -207 +STS_BAD_POINT = -207 +StsBadSize = -201 +STS_BAD_SIZE = -201 +StsDivByZero = -202 +STS_DIV_BY_ZERO = -202 +StsError = -2 +STS_ERROR = -2 +StsFilterOffsetErr = -31 +STS_FILTER_OFFSET_ERR = -31 +StsFilterStructContentErr = -29 +STS_FILTER_STRUCT_CONTENT_ERR = -29 +StsInplaceNotSupported = -203 +STS_INPLACE_NOT_SUPPORTED = -203 +StsInternal = -3 +STS_INTERNAL = -3 +StsKernelStructContentErr = -30 +STS_KERNEL_STRUCT_CONTENT_ERR = -30 +StsNoConv = -7 +STS_NO_CONV = -7 +StsNoMem = -4 +STS_NO_MEM = -4 +StsNotImplemented = -213 +STS_NOT_IMPLEMENTED = -213 +StsNullPtr = -27 +STS_NULL_PTR = -27 +StsObjectNotFound = -204 +STS_OBJECT_NOT_FOUND = -204 +StsOk = 0 # noqa: Y015 +STS_OK = 0 # noqa: Y015 +StsOutOfRange = -211 +STS_OUT_OF_RANGE = -211 +StsParseError = -212 +STS_PARSE_ERROR = -212 +StsUnmatchedFormats = -205 +STS_UNMATCHED_FORMATS = -205 +StsUnmatchedSizes = -209 +STS_UNMATCHED_SIZES = -209 +StsUnsupportedFormat = -210 +STS_UNSUPPORTED_FORMAT = -210 +StsVecLengthErr = -28 +STS_VEC_LENGTH_ERR = -28 diff --git a/typings/cv2-stubs/__init__.pyi b/typings/cv2-stubs/__init__.pyi index 1c1af721..1d84fa2d 100644 --- a/typings/cv2-stubs/__init__.pyi +++ b/typings/cv2-stubs/__init__.pyi @@ -2,10 +2,13 @@ # Library: cv2, version: 4.4.0 # Module: cv2.cv2, version: 4.4.0 import builtins as _mod_builtins +from dataclasses import dataclass import typing import cv2 as _mod_cv2 import numpy as np +import cv2.Error as Error +__all__ = ["Error"] Mat = np.ndarray[int, np.dtype[np.generic]] @@ -2235,7 +2238,14 @@ def erode(src: Mat, kernel, dts: Mat = ..., anchor=..., iterations=..., borderTy "erode(src, kernel[, dst[, anchor[, iterations[, borderType[, borderValue]]]]]) -> dst\n. @brief Erodes an image by using a specific structuring element.\n. \n. The function erodes the source image using the specified structuring element that determines the\n. shape of a pixel neighborhood over which the minimum is taken:\n. \n. \\f[\\texttt{dst} (x,y) = \\min _{(x',y'): \\, \\texttt{element} (x',y') \\ne0 } \\texttt{src} (x+x',y+y')\\f]\n. \n. The function supports the in-place mode. Erosion can be applied several ( iterations ) times. In\n. case of multi-channel images, each channel is processed independently.\n. \n. @param src input image; the number of channels can be arbitrary, but the depth should be one of\n. CV_8U, CV_16U, CV_16S, CV_32F or CV_64F.\n. @param dst output image of the same size and type as src.\n. @param kernel structuring element used for erosion; if `element=Mat()`, a `3 x 3` rectangular\n. structuring element is used. Kernel can be created using #getStructuringElement.\n. @param anchor position of the anchor within the element; default value (-1, -1) means that the\n. anchor is at the element center.\n. @param iterations number of times erosion is applied.\n. @param borderType pixel extrapolation method, see #BorderTypes. #BORDER_WRAP is not supported.\n. @param borderValue border value in case of a constant border\n. @sa dilate, morphologyEx, getStructuringElement" ... -error = _mod_cv2.error +class error(Exception): + code: int + err: str + file: str + func: str + line: int + msg: str + def estimateAffine2D(from_, to, inliers=..., method: int = ..., ransacReprojThreshold=..., maxIters=..., confidence=..., refineIters=...) -> typing.Any: 'estimateAffine2D(from, to[, inliers[, method[, ransacReprojThreshold[, maxIters[, confidence[, refineIters]]]]]]) -> retval, inliers\n. @brief Computes an optimal affine transformation between two 2D point sets.\n. \n. It computes\n. \\f[\n. \\begin{bmatrix}\n. x\\\\\n. y\\\\\n. \\end{bmatrix}\n. =\n. \\begin{bmatrix}\n. a_{11} & a_{12}\\\\\n. a_{21} & a_{22}\\\\\n. \\end{bmatrix}\n. \\begin{bmatrix}\n. X\\\\\n. Y\\\\\n. \\end{bmatrix}\n. +\n. \\begin{bmatrix}\n. b_1\\\\\n. b_2\\\\\n. \\end{bmatrix}\n. \\f]\n. \n. @param from First input 2D point set containing \\f$(X,Y)\\f$.\n. @param to Second input 2D point set containing \\f$(x,y)\\f$.\n. @param inliers Output vector indicating which points are inliers (1-inlier, 0-outlier).\n. @param method Robust method used to compute transformation. The following methods are possible:\n. - cv::RANSAC - RANSAC-based robust method\n. - cv::LMEDS - Least-Median robust method\n. RANSAC is the default method.\n. @param ransacReprojThreshold Maximum reprojection error in the RANSAC algorithm to consider\n. a point as an inlier. Applies only to RANSAC.\n. @param maxIters The maximum number of robust method iterations.\n. @param confidence Confidence level, between 0 and 1, for the estimated transformation. Anything\n. between 0.95 and 0.99 is usually good enough. Values too close to 1 can slow down the estimation\n. significantly. Values lower than 0.8-0.9 can result in an incorrectly estimated transformation.\n. @param refineIters Maximum number of iterations of refining algorithm (Levenberg-Marquardt).\n. Passing 0 will disable refining, so the output matrix will be output of robust method.\n. \n. @return Output 2D affine transformation matrix \\f$2 \\times 3\\f$ or empty matrix if transformation\n. could not be estimated. The returned matrix has the following form:\n. \\f[\n. \\begin{bmatrix}\n. a_{11} & a_{12} & b_1\\\\\n. a_{21} & a_{22} & b_2\\\\\n. \\end{bmatrix}\n. \\f]\n. \n. The function estimates an optimal 2D affine transformation between two 2D point sets using the\n. selected robust algorithm.\n. \n. The computed transformation is then refined further (using only inliers) with the\n. Levenberg-Marquardt method to reduce the re-projection error even more.\n. \n. @note\n. The RANSAC method can handle practically any ratio of outliers but needs a threshold to\n. distinguish inliers from outliers. The method LMeDS does not need any threshold but it works\n. correctly only when there are more than 50% of inliers.\n. \n. @sa estimateAffinePartial2D, getAffineTransform' ... From 8581fcbc9fa2f8e48dd418ffb8086491b2e2f580 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 23 Jul 2022 20:03:21 -0400 Subject: [PATCH 21/28] Fixed error from trying to capture occupied capture device --- .vscode/settings.json | 3 ++- src/capture_method/VideoCaptureDeviceCaptureMethod.py | 9 ++++++++- src/capture_method/__init__.py | 6 +++--- src/compare.py | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c249f12c..54a31a74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,8 @@ "editor.tabSize": 2, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true, + // FIXME: fixAll remove "unused" imports. Let's try to figure out why + "source.fixAll": false, "source.organizeImports": true, }, "files.insertFinalNewline": true, diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index fb737119..83bd3452 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -23,7 +23,14 @@ class VideoCaptureDeviceCaptureMethod(CaptureMethodInterface): def __read_loop(self, autosplit: AutoSplit): try: while not self.stop_thread.is_set(): - result, image = self.capture_device.read() + try: + result, image = self.capture_device.read() + except cv2.error as error: + if error.code != cv2.Error.STS_ERROR: + raise + # STS_ERROR most likely means the camera is occupied + result = False + image = None self.last_captured_frame = image if result else None self.is_old_image = False except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 9179d9e5..bb42e031 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -214,9 +214,9 @@ async def get_camera_info(index: int, device_name: str): backend = "" try: # https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d - backend = video_capture.getBackendName() - video_capture.grab() - except cv2.error as error: # pyright: ignore [reportUnknownVariableType] + backend = video_capture.getBackendName() # STS_ASSERT + video_capture.grab() # STS_ERROR + except cv2.error as error: return CameraInfo(index, device_name, True, backend) \ if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) \ else None diff --git a/src/compare.py b/src/compare.py index 44739d45..efaa313c 100644 --- a/src/compare.py +++ b/src/compare.py @@ -114,7 +114,7 @@ def check_if_image_has_transparency(image: cv2.Mat): # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) if image.shape[2] != 4: return False - mean: float = np.mean(image[:, :, 3]) + mean: float = np.mean(image[:, :, 3]) # pyright: ignore [reportGeneralTypeIssues] if mean == 0: # Non-transparent images code path is usually faster and simpler, so let's return that return False From 4ce5a736516ee184efca07e8b91937c5527675d3 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sat, 23 Jul 2022 23:32:06 -0400 Subject: [PATCH 22/28] Updated some settigns and linters --- .flake8 | 2 +- .vscode/settings.json | 9 ++++++--- scripts/requirements-dev.txt | 9 ++++++--- scripts/requirements.txt | 3 +-- src/capture_method/WindowsGraphicsCaptureMethod.py | 2 +- src/compare.py | 2 +- src/menu_bar.py | 2 +- src/utils.py | 8 ++++++-- typings/imagehash/__init__.pyi | 4 ++-- typings/keyboard/_keyboard_event.pyi | 2 -- 10 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.flake8 b/.flake8 index 147db040..3b8e475f 100644 --- a/.flake8 +++ b/.flake8 @@ -23,4 +23,4 @@ ignore-names=closeEvent,paintEvent,keyPressEvent,mousePressEvent,mouseMoveEvent, ; McCabe max-complexity is also taken care of by Pylint and doesn't fail the build there ; So this is the hard limit max-complexity=32 -inline-quotes=" +inline-quotes=double diff --git a/.vscode/settings.json b/.vscode/settings.json index 54a31a74..18f24f81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,8 +12,7 @@ "editor.tabSize": 2, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - // FIXME: fixAll remove "unused" imports. Let's try to figure out why - "source.fixAll": false, + "source.fixAll": true, "source.organizeImports": true, }, "files.insertFinalNewline": true, @@ -54,7 +53,11 @@ // 88, // Black default 99, // PEP8-17 acceptable max 120, // Our hard rule - ] + ], + "editor.codeActionsOnSave": { + // This removes "unused" imports. https://github.com/microsoft/pylance-release/issues/3091 + "source.fixAll": false, + }, }, "python.linting.enabled": true, "python.linting.pylintEnabled": true, diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index b0c84c99..2365b95a 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -1,16 +1,19 @@ # Dependencies -r requirements.txt # -# Linting, formatters and Types +# Linting and formatters bandit isort flake8 -flake8-pyi +flake8-pyi>=22.7 # New checks flake8-quotes flake8-isort -pylint>=2.13.9 +pep8-naming +pylint>=2.13.9 # Respect ignore configuration options with --recursive=y +# Types git+https://github.com/Avasam/pywin32-stubs.git#egg=pywin32-stubs # https://github.com/kaluluosi/pywin32-stubs/pull/8 types-requests +typing-extensions # # You can comment this out if you don't want to run `designer.bat` to quickly open the bundled PyQt Designer. # Can also be downloaded externally as a non-python package diff --git a/scripts/requirements.txt b/scripts/requirements.txt index a6dfc4fe..314dfb8f 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -10,7 +10,7 @@ # Creating AutoSplit.exe with PyInstaller: ./scripts/build.ps1 # # Dependencies: -numpy>=1.22.0rc1,<1.23 # Type issue +numpy>=1.21.4 # Python 3.10 support opencv-python-headless>=4.5.4,<4.6 # https://github.com/pyinstaller/pyinstaller/issues/6889 PyQt6>=6.2.1 PySide6 @@ -24,7 +24,6 @@ certifi toml psutil pygrabber -typing-extensions # Windows-only pywin32>=301 winsdk>=v1.0.0b4 diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index a430a117..04fd41d1 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -74,7 +74,7 @@ def close(self, autosplit: AutoSplit): # 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 # pylint: disable=line-too-long - # "AutoSplit.exe " # noqa: E501 + # "AutoSplit.exe " # noqa E501 pass self.session = None diff --git a/src/compare.py b/src/compare.py index efaa313c..44739d45 100644 --- a/src/compare.py +++ b/src/compare.py @@ -114,7 +114,7 @@ def check_if_image_has_transparency(image: cv2.Mat): # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) if image.shape[2] != 4: return False - mean: float = np.mean(image[:, :, 3]) # pyright: ignore [reportGeneralTypeIssues] + mean: float = np.mean(image[:, :, 3]) if mean == 0: # Non-transparent images code path is usually faster and simpler, so let's return that return False diff --git a/src/menu_bar.py b/src/menu_bar.py index adff9fea..aa382b96 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -14,7 +14,7 @@ import user_profile from capture_method import (CAPTURE_METHODS, CameraInfo, CaptureMethodEnum, change_capture_method, get_all_video_capture_devices) -from gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa: F401 +from gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa F401 from hotkeys import HOTKEYS, Hotkeys, set_hotkey from utils import AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, WINDOWS_BUILD_NUMBER, decimal diff --git a/src/utils.py b/src/utils.py index de07952c..5f52e6d4 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import ctypes import ctypes.wintypes @@ -5,14 +7,16 @@ import sys from collections.abc import Callable, Iterable from platform import version -from typing import Any, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import cv2 -from typing_extensions import TypeGuard from win32 import win32gui from gen.build_number import AUTOSPLIT_BUILD_NUMBER +if TYPE_CHECKING: + from typing_extensions import TypeGuard + DWMWA_EXTENDED_FRAME_BOUNDS = 9 diff --git a/typings/imagehash/__init__.pyi b/typings/imagehash/__init__.pyi index 5e7ee80c..a2866bdb 100644 --- a/typings/imagehash/__init__.pyi +++ b/typings/imagehash/__init__.pyi @@ -3,7 +3,7 @@ This type stub file was generated by pyright. https://github.com/JohannesBuchner/imagehash/issues/151 """ -from __future__ import absolute_import, annotations, division, print_function +from __future__ import absolute_import, division, print_function from PIL import Image @@ -80,7 +80,7 @@ class ImageMultiHash: def __ne__(self, other) -> bool: ... - def __sub__(self, other, hamming_cutoff=..., bit_error_rate=...) -> int | float: + def __sub__(self, other, hamming_cutoff=..., bit_error_rate=...) -> float: ... def __hash__(self) -> int: diff --git a/typings/keyboard/_keyboard_event.pyi b/typings/keyboard/_keyboard_event.pyi index b5725f33..900a65d5 100644 --- a/typings/keyboard/_keyboard_event.pyi +++ b/typings/keyboard/_keyboard_event.pyi @@ -2,8 +2,6 @@ This type stub file was generated by pyright. https://github.com/boppreh/keyboard/issues/505 """ -from __future__ import annotations - from typing import Any, Literal Unknown = Any From 0d26e5806bee7c0ba6b07417860c5b6b3f294128 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 24 Jul 2022 19:08:08 -0400 Subject: [PATCH 23/28] Completed fire_and_forget implementation --- src/hotkeys.py | 6 +++--- src/menu_bar.py | 10 +++++----- src/utils.py | 14 +++++++++++++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/hotkeys.py b/src/hotkeys.py index 9048dcd6..d608669c 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,13 +1,12 @@ from __future__ import annotations from collections.abc import Callable -from threading import Thread from typing import TYPE_CHECKING, Literal, Optional, Union import keyboard import pyautogui -from utils import START_AUTO_SPLITTER_TEXT, is_digit +from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -217,6 +216,7 @@ def set_hotkey(autosplit: AutoSplit, hotkey: Hotkeys, preselected_hotkey_name: s # New thread points to callback. 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() @@ -248,4 +248,4 @@ def callback(): # Try to remove the previously set hotkey if there is one. _unhook(getattr(autosplit, f"{hotkey}_hotkey")) - Thread(target=callback).start() + callback() diff --git a/src/menu_bar.py b/src/menu_bar.py index aa382b96..514f4c5d 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -2,7 +2,6 @@ import asyncio import webbrowser -from threading import Thread from typing import TYPE_CHECKING, Any, Union, cast import requests @@ -16,7 +15,7 @@ get_all_video_capture_devices) from gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa F401 from hotkeys import HOTKEYS, Hotkeys, set_hotkey -from utils import AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, WINDOWS_BUILD_NUMBER, decimal +from utils import AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, WINDOWS_BUILD_NUMBER, decimal, fire_and_forget if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -152,8 +151,9 @@ def __capture_device_changed(self): if self.autosplit.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: change_capture_method(CaptureMethodEnum.VIDEO_CAPTURE_DEVICE, self.autosplit) - async def __set_all_capture_devices(self): - self.__video_capture_devices = await get_all_video_capture_devices() + @fire_and_forget + def __set_all_capture_devices(self): + self.__video_capture_devices = asyncio.run(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) @@ -183,7 +183,7 @@ def __init__(self, autosplit: AutoSplit): # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() - Thread(target=lambda: asyncio.run(self.__set_all_capture_devices())).start() + self.__set_all_capture_devices() capture_list_items = [ f"- {method.name} ({method.short_description})" for method in capture_method_values diff --git a/src/utils.py b/src/utils.py index 5f52e6d4..ea167fcf 100644 --- a/src/utils.py +++ b/src/utils.py @@ -7,6 +7,7 @@ import sys from collections.abc import Callable, Iterable from platform import version +from threading import Thread from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import cv2 @@ -73,8 +74,19 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: return window_left_bounds, window_top_bounds, window_width, window_height -def fire_and_forget(func: Callable[..., None]): +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'.` + + Uses asyncio on Linux because of a `Segmentation fault (core dumped)` + """ def wrapped(*args: Any, **kwargs: Any): + if sys.platform == "win32": + 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 wrapped From 7eed44481a36bf61fc3d6102264643bcd333de38 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 24 Jul 2022 20:45:30 -0400 Subject: [PATCH 24/28] Fixed a few focus and tabstop issues --- res/about.ui | 3 +++ res/settings.ui | 17 ++++++++++------- src/hotkeys.py | 5 ++++- src/menu_bar.py | 5 ++++- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/res/about.ui b/res/about.ui index d128ab6b..05e4914a 100644 --- a/res/about.ui +++ b/res/about.ui @@ -128,6 +128,9 @@ consider donating. Thank you! true + icon_label + version_label + created_by_label diff --git a/res/settings.ui b/res/settings.ui index 6a5d299d..decb2a63 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -652,17 +652,20 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i - split_input - reset_input - undo_split_input - skip_split_input - pause_input + set_split_hotkey_button + set_reset_hotkey_button + set_undo_split_hotkey_button + set_skip_split_hotkey_button + set_pause_hotkey_button + fps_limit_spinbox + live_capture_region_checkbox + capture_method_combobox + capture_device_combobox default_comparison_method default_similarity_threshold_spinbox + default_delay_time_spinbox default_pause_time_spinbox loop_splits_checkbox - fps_limit_spinbox - live_capture_region_checkbox diff --git a/src/hotkeys.py b/src/hotkeys.py index d608669c..f5f006fb 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -1,10 +1,11 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Union, cast import keyboard import pyautogui +from PyQt6 import QtWidgets from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit @@ -209,6 +210,8 @@ def __get_hotkey_action(autosplit: AutoSplit, hotkey: Hotkeys): def set_hotkey(autosplit: AutoSplit, hotkey: Hotkeys, preselected_hotkey_name: str = ""): if autosplit.SettingsWidget: + # Unfocus all fields + cast(QtWidgets.QDialog, autosplit.SettingsWidget).setFocus() getattr(autosplit.SettingsWidget, f"set_{hotkey}_hotkey_button").setText(PRESS_A_KEY_TEXT) # Disable some buttons diff --git a/src/menu_bar.py b/src/menu_bar.py index 514f4c5d..14df8ca6 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -48,6 +48,7 @@ def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, che self.design_window = design_window if version_parse(latest_version) > version_parse(AUTOSPLIT_VERSION): self.do_not_ask_again_checkbox.setVisible(check_on_open) + self.left_button.setFocus() self.show() elif not check_on_open: self.update_status_label.setText("You are on the latest AutoSplit version.") @@ -171,6 +172,7 @@ def __set_all_capture_devices(self): def __init__(self, autosplit: AutoSplit): super().__init__() self.setupUi(self) + self.autosplit = autosplit # Spinbox frame disappears and reappears on Windows 11. It's much cleaner to just disable them. # Most likely related: https://bugreports.qt.io/browse/QTBUG-95215?jql=labels%20%3D%20Windows11 # Arrow buttons tend to move a lot as well @@ -179,7 +181,8 @@ def __init__(self, autosplit: AutoSplit): self.default_similarity_threshold_spinbox.setFrame(False) self.default_delay_time_spinbox.setFrame(False) self.default_pause_time_spinbox.setFrame(False) - self.autosplit = autosplit + # Don't autofocus any particular field + self.setFocus() # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() From 3683954e69f8e862a374e95cb3fbee6d5d1c37ed Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 24 Jul 2022 21:57:46 -0400 Subject: [PATCH 25/28] Fix numpy typing --- src/compare.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/compare.py b/src/compare.py index 44739d45..8a91347a 100644 --- a/src/compare.py +++ b/src/compare.py @@ -1,7 +1,7 @@ from __future__ import annotations from math import sqrt -from typing import Optional +from typing import Any, Optional, cast import cv2 import imagehash # https://github.com/JohannesBuchner/imagehash/issues/151 @@ -114,7 +114,8 @@ def check_if_image_has_transparency(image: cv2.Mat): # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) if image.shape[2] != 4: return False - mean: float = np.mean(image[:, :, 3]) + # Needs casting for numpy>=1.23 https://github.com/numpy/numpy/issues/20099 + mean: float = np.mean(cast(Any, image[:, :, 3])) if mean == 0: # Non-transparent images code path is usually faster and simpler, so let's return that return False From 18810191cb11fb84fdaeefc6b2e9137195cef161 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 25 Jul 2022 19:10:18 -0400 Subject: [PATCH 26/28] Prefer asyncio over threads --- .vscode/launch.json | 13 ++++++++++ .vscode/tasks.json | 7 +++++- src/AutoControlledWorker.py | 45 ---------------------------------- src/AutoSplit.py | 24 ++++++------------ src/auto_control.py | 40 ++++++++++++++++++++++++++++++ src/capture_method/__init__.py | 16 +++++++++++- src/menu_bar.py | 38 +++++++--------------------- src/utils.py | 23 ++++++++--------- 8 files changed, 103 insertions(+), 103 deletions(-) delete mode 100644 src/AutoControlledWorker.py create mode 100644 src/auto_control.py diff --git a/.vscode/launch.json b/.vscode/launch.json index dec98523..db52528d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,9 +8,22 @@ "name": "Python: AutoSplit", "type": "python", "request": "launch", + "preLaunchTask": "Compile resources", "program": "src/AutoSplit.py", "console": "integratedTerminal", "justMyCode": true + }, + { + "name": "Python: AutoSplit --auto-controlled", + "type": "python", + "request": "launch", + "preLaunchTask": "Compile resources", + "program": "src/AutoSplit.py", + "args": [ + "--auto-controlled" + ], + "console": "integratedTerminal", + "justMyCode": true } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 26a9e03d..69828ccd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,11 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Compile resources", + "type": "shell", + "command": "scripts/compile_resources.ps1" + }, { "label": "Build AutoSplit", "type": "shell", @@ -10,5 +15,5 @@ "isDefault": true } } - ], + ] } diff --git a/src/AutoControlledWorker.py b/src/AutoControlledWorker.py deleted file mode 100644 index 5b3238c1..00000000 --- a/src/AutoControlledWorker.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PyQt6 import QtCore - -import error_messages -import user_profile - -if TYPE_CHECKING: - from AutoSplit import AutoSplit - - -class AutoControlledWorker(QtCore.QObject): - def __init__(self, autosplit: AutoSplit): - self.autosplit = autosplit - super().__init__() - - def run(self): - while True: - try: - line = input() - except RuntimeError: - self.autosplit.show_error_signal.emit(error_messages.stdin_lost) - break - except EOFError: - continue - # This is for use in a Development environment - if line == "kill": - self.autosplit.closeEvent() - break - if line == "start": - self.autosplit.start_auto_splitter() - elif line in {"split", "skip"}: - self.autosplit.skip_split_signal.emit() - elif line == "undo": - self.autosplit.undo_split_signal.emit() - elif line == "reset": - self.autosplit.reset_signal.emit() - elif line.startswith("settings"): - # Allow for any split character between "settings" and the path - user_profile.load_settings(self.autosplit, line[9:]) - # TODO: Not yet implemented in AutoSplit Integration - # elif line == 'pause': - # self.pause_signal.emit() diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 15b9fbd1..47836845 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import asyncio import ctypes import os import signal @@ -19,13 +20,12 @@ import error_messages import user_profile -from AutoControlledWorker import AutoControlledWorker +from auto_control import start_auto_control_loop from AutoSplitImage import COMPARISON_RESIZE, START_KEYWORD, AutoSplitImage, ImageType from capture_method import CaptureMethodEnum, CaptureMethodInterface from gen import about, design, settings, update_checker from hotkeys import HOTKEYS, after_setting_hotkey, send_command -from menu_bar import (check_for_updates, get_default_settings_from_ui, open_about, open_settings, open_update_checker, - view_help) +from menu_bar import check_for_updates, get_default_settings_from_ui, open_about, open_settings, 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 user_profile import DEFAULT_PROFILE @@ -64,7 +64,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # Widgets AboutWidget: Optional[about.Ui_AboutAutoSplitWidget] = None UpdateCheckerWidget: Optional[update_checker.Ui_UpdateChecker] = None - CheckForUpdatesThread: Optional[QtCore.QThread] = None SettingsWidget: Optional[settings.Ui_DialogSettings] = None # Initialize a few attributes @@ -95,7 +94,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): reset_image: Optional[AutoSplitImage] = None split_images: list[AutoSplitImage] = [] split_image: Optional[AutoSplitImage] = None - update_auto_control: Optional[QtCore.QThread] = None + auto_control_loop: Optional[asyncio.Future[None]] = None def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-many-statements super().__init__(parent) @@ -137,7 +136,6 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man self.SettingsWidget.skip_split_input.setEnabled(False) self.SettingsWidget.undo_split_input.setEnabled(False) self.SettingsWidget.pause_input.setEnabled(False) - if self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) @@ -146,11 +144,7 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man print(f"{AUTOSPLIT_VERSION}\n{os.getpid()}", flush=True) # Use and Start the thread that checks for updates from LiveSplit - self.update_auto_control = QtCore.QThread() - worker = AutoControlledWorker(self) - worker.moveToThread(self.update_auto_control) - self.update_auto_control.started.connect(worker.run) - self.update_auto_control.start() + self.auto_control_loop = start_auto_control_loop(self) # split image folder line edit text self.split_image_folder_input.setText("No Folder Selected") @@ -183,8 +177,6 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man # connect signals to functions self.after_setting_hotkey_signal.connect(lambda: after_setting_hotkey(self)) self.start_auto_splitter_signal.connect(self.__auto_splitter) - self.update_checker_widget_signal.connect(lambda latest_version, check_on_open: - open_update_checker(self, latest_version, check_on_open)) self.load_start_image_signal.connect(self.__load_start_image) self.load_start_image_signal[bool].connect(self.__load_start_image) self.load_start_image_signal[bool, bool].connect(self.__load_start_image) @@ -819,8 +811,8 @@ def closeEvent(self, a0: Optional[QtGui.QCloseEvent] = None): """ def exit_program(): - if self.update_auto_control: - self.update_auto_control.terminate() + if self.auto_control_loop: + self.auto_control_loop.cancel() if a0 is not None: a0.accept() if self.is_auto_controlled: @@ -828,7 +820,7 @@ def exit_program(): os.kill(os.getpid(), signal.SIGINT) sys.exit() - # Simulates LiveSplit quitting without asking. See "TODO" at update_auto_control Worker + # Simulates LiveSplit quitting without asking. See "TODO" at auto_control_loop Worker # This also more gracefully exits LiveSplit # Users can still manually save their settings if a0 is None: diff --git a/src/auto_control.py b/src/auto_control.py new file mode 100644 index 00000000..eab0ad8b --- /dev/null +++ b/src/auto_control.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import error_messages +import user_profile +from utils import fire_and_forget + +if TYPE_CHECKING: + from AutoSplit import AutoSplit + + +@fire_and_forget +def start_auto_control_loop(autosplit: AutoSplit): + while True: + try: + line = input() + except RuntimeError: + autosplit.show_error_signal.emit(error_messages.stdin_lost) + break + except EOFError: + continue + # This is for use in a Development environment + if line == "kill": + autosplit.closeEvent() + break + if line == "start": + autosplit.start_auto_splitter() + elif line in {"split", "skip"}: + autosplit.skip_split_signal.emit() + elif line == "undo": + autosplit.undo_split_signal.emit() + elif line == "reset": + autosplit.reset_signal.emit() + elif line.startswith("settings"): + # Allow for any split character between "settings" and the path + user_profile.load_settings(autosplit, line[9:]) + # TODO: Not yet implemented in AutoSplit Integration + # elif line == 'pause': + # autosplit.pause_signal.emit() diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index bb42e031..820bfe4c 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -4,7 +4,7 @@ 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, Union, cast import cv2 from pygrabber.dshow_graph import FilterGraph @@ -76,7 +76,21 @@ def __hash__(self): class CaptureMethodDict(OrderedDict[CaptureMethodEnum, CaptureMethodInfo]): + def get_index(self, capture_method: Union[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 first (default) capture method if the index is invalid. + + Returns `CaptureMethodEnum.NONE` if there are no capture method available. + """ if len(self) <= 0: return CaptureMethodEnum.NONE if index < 0: diff --git a/src/menu_bar.py b/src/menu_bar.py index bba9ddda..bea51240 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -2,7 +2,7 @@ import asyncio import webbrowser -from typing import TYPE_CHECKING, Any, Union, cast +from typing import TYPE_CHECKING, Any, cast import requests from packaging.version import parse as version_parse @@ -76,35 +76,15 @@ def view_help(): webbrowser.open("https://github.com/Toufool/Auto-Split#tutorial") -class __CheckForUpdatesThread(QtCore.QThread): - def __init__(self, autosplit: AutoSplit, check_on_open: bool): - super().__init__() - self.autosplit = autosplit - self.check_on_open = check_on_open - - def run(self): - try: - response = requests.get("https://api.github.com/repos/Toufool/Auto-Split/releases/latest") - latest_version = str(response.json()["name"]).split("v")[1] - self.autosplit.update_checker_widget_signal.emit(latest_version, self.check_on_open) - except (RequestException, KeyError): - if not self.check_on_open: - self.autosplit.show_error_signal.emit(error_messages.check_for_updates) - - +@fire_and_forget def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): - autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open) - autosplit.CheckForUpdatesThread.start() - - -def get_capture_method_index(capture_method: Union[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 + response = requests.get("https://api.github.com/repos/Toufool/Auto-Split/releases/latest") + latest_version = str(response.json()["name"]).split("v")[1] + autosplit.update_checker_widget_signal.emit(latest_version, check_on_open) + except (RequestException, KeyError): + if not check_on_open: + autosplit.show_error_signal.emit(error_messages.check_for_updates) class __SettingsWidget(QtWidgets.QDialog, settings_ui.Ui_DialogSettings): @@ -226,7 +206,7 @@ def hotkey_connect(hotkey: Hotkeys): 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"])) # Image Settings self.default_comparison_method.setCurrentIndex(autosplit.settings_dict["default_comparison_method"]) diff --git a/src/utils.py b/src/utils.py index ea167fcf..1cf3576e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -7,7 +7,6 @@ import sys from collections.abc import Callable, Iterable from platform import version -from threading import Thread from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import cv2 @@ -74,20 +73,22 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: return window_left_bounds, window_top_bounds, window_width, window_height -def fire_and_forget(func: Callable[..., Any]): - """ - Runs synchronous function asynchronously without waiting for a response +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() - Uses threads on Windows because `RuntimeError: There is no current event loop in thread 'MainThread'.` - Uses asyncio on Linux because of a `Segmentation fault (core dumped)` +def fire_and_forget(func: Callable[..., Any]): + """ + Runs synchronous function asynchronously without waiting for a response. + Uses asyncio to avoid a multitude of possible problems with threads. """ def wrapped(*args: Any, **kwargs: Any): - if sys.platform == "win32": - 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 From 9967c6906f055dbf94de3528000e6fd4b5400c55 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 25 Jul 2022 22:14:23 -0400 Subject: [PATCH 27/28] Wrap async functions for errors --- src/auto_control.py | 56 +++++++++++++++++++++++-------------------- src/error_messages.py | 2 +- src/menu_bar.py | 35 ++++++++++++++++----------- src/utils.py | 12 +++++++--- 4 files changed, 61 insertions(+), 44 deletions(-) diff --git a/src/auto_control.py b/src/auto_control.py index eab0ad8b..c1fab203 100644 --- a/src/auto_control.py +++ b/src/auto_control.py @@ -12,29 +12,33 @@ @fire_and_forget def start_auto_control_loop(autosplit: AutoSplit): - while True: - try: - line = input() - except RuntimeError: - autosplit.show_error_signal.emit(error_messages.stdin_lost) - break - except EOFError: - continue - # This is for use in a Development environment - if line == "kill": - autosplit.closeEvent() - break - if line == "start": - autosplit.start_auto_splitter() - elif line in {"split", "skip"}: - autosplit.skip_split_signal.emit() - elif line == "undo": - autosplit.undo_split_signal.emit() - elif line == "reset": - autosplit.reset_signal.emit() - elif line.startswith("settings"): - # Allow for any split character between "settings" and the path - user_profile.load_settings(autosplit, line[9:]) - # TODO: Not yet implemented in AutoSplit Integration - # elif line == 'pause': - # autosplit.pause_signal.emit() + try: + while True: + try: + line = input() + except (RuntimeError, ValueError): + autosplit.show_error_signal.emit(error_messages.stdin_lost) + break + except EOFError: + continue + # This is for use in a Development environment + if line == "kill": + autosplit.closeEvent() + break + if line == "start": + autosplit.start_auto_splitter() + elif line in {"split", "skip"}: + autosplit.skip_split_signal.emit() + elif line == "undo": + autosplit.undo_split_signal.emit() + elif line == "reset": + autosplit.reset_signal.emit() + elif line.startswith("settings"): + # Allow for any split character between "settings" and the path + user_profile.load_settings(autosplit, line[9:]) + # TODO: Not yet implemented in AutoSplit Integration + # elif line == 'pause': + # autosplit.pause_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)) diff --git a/src/error_messages.py b/src/error_messages.py index edd4ca86..a1090b5a 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -122,7 +122,7 @@ 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, lost or closed, external control like LiveSplit integration will not work.") def already_running(): diff --git a/src/menu_bar.py b/src/menu_bar.py index bea51240..7ac6acc9 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -85,6 +85,9 @@ def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): except (RequestException, KeyError): if not check_on_open: autosplit.show_error_signal.emit(error_messages.check_for_updates) + 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)) class __SettingsWidget(QtWidgets.QDialog, settings_ui.Ui_DialogSettings): @@ -134,20 +137,24 @@ def __capture_device_changed(self): @fire_and_forget def __set_all_capture_devices(self): - self.__video_capture_devices = asyncio.run(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) - self.capture_device_combobox.addItems([ - f"* {device.name}" - + (f" [{device.backend}]" if device.backend else "") - + (" (occupied)" if device.occupied else "") - for device in self.__video_capture_devices]) - self.capture_device_combobox.setEnabled(True) - self.capture_device_combobox.setCurrentIndex( - self.get_capture_device_index(self.autosplit.settings_dict["capture_device_id"])) - else: - self.capture_device_combobox.setPlaceholderText("No device found.") + try: + self.__video_capture_devices = asyncio.run(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) + self.capture_device_combobox.addItems([ + f"* {device.name}" + + (f" [{device.backend}]" if device.backend else "") + + (" (occupied)" if device.occupied else "") + for device in self.__video_capture_devices]) + self.capture_device_combobox.setEnabled(True) + self.capture_device_combobox.setCurrentIndex( + self.get_capture_device_index(self.autosplit.settings_dict["capture_device_id"])) + else: + self.capture_device_combobox.setPlaceholderText("No device found.") + except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here + error = exception + self.autosplit.show_error_signal.emit(lambda: error_messages.exception_traceback(error)) def __init__(self, autosplit: AutoSplit): super().__init__() diff --git a/src/utils.py b/src/utils.py index 1cf3576e..9de79fdd 100644 --- a/src/utils.py +++ b/src/utils.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import cv2 +from typing_extensions import ParamSpec, TypeGuard from win32 import win32gui from gen.build_number import AUTOSPLIT_BUILD_NUMBER @@ -82,13 +83,18 @@ def get_or_create_eventloop(): return asyncio.get_event_loop() -def fire_and_forget(func: Callable[..., Any]): +P = ParamSpec("P") + + +def fire_and_forget(func: Callable[P, Any]) -> Callable[P, asyncio.Future[None]]: """ Runs synchronous function asynchronously without waiting for a response. Uses asyncio to avoid a multitude of possible problems with threads. + + Remember to also wrap the function in a try-except to catch any unhandled exceptions! """ - def wrapped(*args: Any, **kwargs: Any): - return get_or_create_eventloop().run_in_executor(None, func, *args, *kwargs) + def wrapped(*args: P.args, **kwargs: P.kwargs): + return get_or_create_eventloop().run_in_executor(None, lambda: func(*args, **kwargs)) return wrapped From ea48b52016d515aef524584d5293e4211174fafa Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 25 Jul 2022 23:39:06 -0400 Subject: [PATCH 28/28] Proper toggle autoreset iamge location and setting Fixed source of truth for default settings --- res/design.ui | 15 ---- res/settings.ui | 61 ++++++++----- src/AutoSplit.py | 90 +++++++++---------- .../VideoCaptureDeviceCaptureMethod.py | 4 +- src/error_messages.py | 33 +++---- src/hotkeys.py | 14 +-- src/menu_bar.py | 40 ++------- src/user_profile.py | 62 +++++++------ src/utils.py | 7 +- 9 files changed, 154 insertions(+), 172 deletions(-) diff --git a/res/design.ui b/res/design.ui index 0e3b93cf..5a00ad65 100644 --- a/res/design.ui +++ b/res/design.ui @@ -888,20 +888,6 @@ > - - - - 10 - 360 - 91 - 31 - - - - Disable auto -reset image - - x_label select_region_button start_auto_splitter_button @@ -938,7 +924,6 @@ reset image image_loop_value_label previous_image_button next_image_button - disable_auto_reset_checkbox diff --git a/res/settings.ui b/res/settings.ui index bd35eb0a..e670fa3b 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -6,8 +6,8 @@ 0 0 - 289 - 621 + 291 + 661 @@ -18,14 +18,14 @@ - 289 - 621 + 291 + 661 - 289 - 621 + 291 + 661 @@ -177,7 +177,7 @@ 10 390 271 - 241 + 261 @@ -196,7 +196,7 @@ 167 - 26 + 25 88 22 @@ -238,7 +238,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 6 - 29 + 28 161 16 @@ -251,7 +251,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 6 - 117 + 118 161 16 @@ -264,7 +264,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 167 - 114 + 115 87 22 @@ -305,7 +305,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 167 - 56 + 55 52 22 @@ -333,7 +333,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 6 - 144 + 143 235 20 @@ -352,7 +352,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 6 - 173 + 193 261 61 @@ -399,6 +399,22 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 999999999 + + + + 6 + 168 + 151 + 20 + + + + Enable auto reset image + + + true + + @@ -406,7 +422,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 10 10 271 - 181 + 191 @@ -438,9 +454,6 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i - - true - 76 @@ -550,7 +563,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 180 - 28 + 30 81 21 @@ -649,7 +662,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i true - + 180 @@ -665,21 +678,21 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i Set Hotkey - + 6 - 150 + 154 71 31 - Disable auto + Toggle auto reset image - + 76 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 47836845..dba0bcd9 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -25,10 +25,9 @@ from capture_method import CaptureMethodEnum, CaptureMethodInterface from gen import about, design, settings, update_checker from hotkeys import HOTKEYS, after_setting_hotkey, send_command -from menu_bar import check_for_updates, get_default_settings_from_ui, open_about, open_settings, view_help +from menu_bar import check_for_updates, open_about, open_settings, 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 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) @@ -38,7 +37,7 @@ os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() -class AutoSplit(QMainWindow, design.Ui_MainWindow): +class AutoSplit(QMainWindow, design.Ui_MainWindow): # pylint: disable=too-many-instance-attributes myappid = f"Toufool.AutoSplit.v{AUTOSPLIT_VERSION}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) @@ -69,18 +68,12 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # Initialize a few attributes hwnd = 0 """Window Handle used for Capture Region""" - last_saved_settings = DEFAULT_PROFILE similarity = 0.0 split_image_number = 0 split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] split_groups: list[list[int]] = [] capture_method = CaptureMethodInterface() - # Last loaded settings empty and last successful loaded settings file path to None until we try to load them - last_loaded_settings = DEFAULT_PROFILE - last_successfully_loaded_settings_file_path: Optional[str] = None - """For when a file has never loaded, but you successfully "Save File As".""" - # Automatic timer start highest_similarity = 0.0 reset_highest_similarity = 0.0 @@ -118,24 +111,17 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man for hotkey in HOTKEYS: setattr(self, f"{hotkey}_hotkey", None) - # Get default values defined in SettingsDialog - self.settings_dict = get_default_settings_from_ui(self) + # Settings / Profile + self.DEFAULT_PROFILE = user_profile.get_default_settings_from_ui(self) # pylint: disable=invalid-name + """Default values defined in SettingsDialog""" + self.settings_dict = self.DEFAULT_PROFILE + self.last_saved_settings = self.DEFAULT_PROFILE + self.last_loaded_settings = self.DEFAULT_PROFILE + """Last loaded settings empty and last successful loaded settings file path to None until we try to load them""" + self.last_successfully_loaded_settings_file_path: Optional[str] = None + """For when a file has never loaded, but you successfully "Save File As".""" user_profile.load_check_for_updates_on_open(self) - self.action_view_help.triggered.connect(view_help) - self.action_about.triggered.connect(lambda: open_about(self)) - 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)) - self.action_save_profile_as.triggered.connect(lambda: user_profile.save_settings_as(self)) - self.action_load_profile.triggered.connect(lambda: user_profile.load_settings(self)) - - if self.SettingsWidget: - self.SettingsWidget.split_input.setEnabled(False) - self.SettingsWidget.reset_input.setEnabled(False) - self.SettingsWidget.skip_split_input.setEnabled(False) - self.SettingsWidget.undo_split_input.setEnabled(False) - self.SettingsWidget.pause_input.setEnabled(False) if self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) @@ -149,6 +135,15 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man # split image folder line edit text self.split_image_folder_input.setText("No Folder Selected") + # Connecting menu actions + self.action_view_help.triggered.connect(view_help) + self.action_about.triggered.connect(lambda: open_about(self)) + 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)) + self.action_save_profile_as.triggered.connect(lambda: user_profile.save_settings_as(self)) + self.action_load_profile.triggered.connect(lambda: user_profile.load_settings(self)) + # Connecting button clicks to functions self.browse_button.clicked.connect(self.__browse) self.select_region_button.clicked.connect(lambda: select_region(self)) @@ -684,6 +679,8 @@ 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) @@ -712,6 +709,8 @@ 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) @@ -753,28 +752,29 @@ def __reset_if_should(self, capture: Optional[cv2.Mat]): """ Checks if we should reset, resets if it's the case, and returns the result """ - if self.disable_auto_reset_checkbox.isChecked(): - self.table_reset_image_live_label.setText("disabled") - elif self.reset_image: - 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) - 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)) + 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) + + paused = time() - self.run_start_time <= self.reset_image.get_pause_time(self) + 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)) + self.table_reset_image_threshold_label.setText(decimal(threshold)) - if should_reset: - send_command(self, "reset") - self.reset() + if should_reset: + send_command(self, "reset") + self.reset() + else: + self.table_reset_image_live_label.setText("disabled") return self.__check_for_reset_state_update_ui() diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 83bd3452..bdb4ed2d 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -37,9 +37,9 @@ def __read_loop(self, autosplit: AutoSplit): error = exception 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)) + + CREATE_NEW_ISSUE_MESSAGE)) def __init__(self, autosplit: AutoSplit): super().__init__() diff --git a/src/error_messages.py b/src/error_messages.py index a1090b5a..f7fdd51c 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -104,12 +104,13 @@ def invalid_settings(): 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") def check_for_updates(): @@ -122,7 +123,7 @@ def load_start_image(): def stdin_lost(): - set_text_message("stdin not supported, lost or closed, 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_running(): @@ -133,19 +134,22 @@ def already_running(): "Ignore") -def exception_traceback(message: str, exception: BaseException): - set_text_message( - message, - "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), - "Close AutoSplit") - - CREATE_NEW_ISSUE_MESSAGE = ( "Please create a New Issue at " + "github.com/Toufool/Auto-Split/issues, describe what happened, " + "and copy & paste the entire error message below") +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") + + def make_excepthook(autosplit: AutoSplit): def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: Optional[TracebackType]): # Catch Keyboard Interrupts for a clean close @@ -158,18 +162,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 fd62a143..0201a1c9 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -20,8 +20,8 @@ Commands = Literal["split", "start", "pause", "reset", "skip", "undo"] -Hotkeys = Literal["split", "reset", "skip_split", "undo_split", "pause", "disable_auto_reset_image"] -HOTKEYS: list[Hotkeys] = ["split", "reset", "skip_split", "undo_split", "pause", "disable_auto_reset_image"] +Hotkeys = Literal["split", "reset", "skip_split", "undo_split", "pause", "toggle_auto_reset_image"] +HOTKEYS: list[Hotkeys] = ["split", "reset", "skip_split", "undo_split", "pause", "toggle_auto_reset_image"] def before_setting_hotkey(autosplit: AutoSplit): @@ -202,9 +202,13 @@ def __get_hotkey_action(autosplit: AutoSplit, hotkey: Hotkeys): return lambda: autosplit.skip_split(True) if hotkey == "undo_split": return lambda: autosplit.undo_split(True) - if hotkey == "disable_auto_reset_image": - return lambda: autosplit.disable_auto_reset_checkbox.setChecked( - not autosplit.disable_auto_reset_checkbox.isChecked()) + if hotkey == "toggle_auto_reset_image": + def toggle_auto_reset_image(): + new_value = not autosplit.settings_dict["enable_auto_reset"] + autosplit.settings_dict["enable_auto_reset"] = new_value + if autosplit.SettingsWidget: + autosplit.SettingsWidget.enable_auto_reset_checkbox.setChecked(new_value) + return toggle_auto_reset_image return getattr(autosplit, f"{hotkey}_signal").emit # TODO: using getattr/setattr is NOT a good way to go about this. It was only temporarily done to diff --git a/src/menu_bar.py b/src/menu_bar.py index 7ac6acc9..c0f55d51 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -204,7 +204,7 @@ def hotkey_connect(hotkey: Hotkeys): 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) @@ -221,6 +221,7 @@ def hotkey_connect(hotkey: Hotkeys): self.default_delay_time_spinbox.setValue(autosplit.settings_dict["default_delay_time"]) self.default_pause_time_spinbox.setValue(autosplit.settings_dict["default_pause_time"]) self.loop_splits_checkbox.setChecked(autosplit.settings_dict["loop_splits"]) + self.enable_auto_reset_checkbox.setChecked(autosplit.settings_dict["enable_auto_reset"]) # endregion # region Binding # Capture Settings @@ -250,6 +251,9 @@ def hotkey_connect(hotkey: Hotkeys): self.loop_splits_checkbox.stateChanged.connect(lambda: self.__set_value( "loop_splits", self.loop_splits_checkbox.isChecked())) + self.enable_auto_reset_checkbox.stateChanged.connect(lambda: self.__set_value( + "enable_auto_reset", + self.enable_auto_reset_checkbox.isChecked())) # endregion self.show() @@ -257,37 +261,3 @@ def hotkey_connect(hotkey: Hotkeys): def open_settings(autosplit: AutoSplit): autosplit.SettingsWidget = __SettingsWidget(autosplit) - - -def get_default_settings_from_ui(autosplit: AutoSplit): - temp_dialog = QtWidgets.QDialog() - default_settings_dialog = settings_ui.Ui_DialogSettings() - default_settings_dialog.setupUi(temp_dialog) - default_settings: user_profile.UserProfileDict = { - "split_hotkey": default_settings_dialog.split_input.text(), - "reset_hotkey": default_settings_dialog.reset_input.text(), - "undo_split_hotkey": default_settings_dialog.undo_split_input.text(), - "skip_split_hotkey": default_settings_dialog.skip_split_input.text(), - "pause_hotkey": default_settings_dialog.pause_input.text(), - "disable_auto_reset_image_hotkey": default_settings_dialog.disable_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( - default_settings_dialog.capture_method_combobox.currentIndex()), - "capture_device_id": default_settings_dialog.capture_device_combobox.currentIndex(), - "capture_device_name": "", - "default_comparison_method": default_settings_dialog.default_comparison_method.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(), - "split_image_directory": autosplit.split_image_folder_input.text(), - "captured_window_title": "", - "capture_region": { - "x": autosplit.x_spinbox.value(), - "y": autosplit.y_spinbox.value(), - "width": autosplit.width_spinbox.value(), - "height": autosplit.height_spinbox.value(), - }} - del temp_dialog - return default_settings diff --git a/src/user_profile.py b/src/user_profile.py index c41e1a39..8a79e994 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 gen import design, settings as settings_ui from hotkeys import HOTKEYS, set_hotkey from utils import auto_split_directory @@ -23,9 +23,10 @@ class UserProfileDict(TypedDict): undo_split_hotkey: str skip_split_hotkey: str pause_hotkey: str - disable_auto_reset_image_hotkey: str + toggle_auto_reset_image_hotkey: str fps_limit: int live_capture_region: bool + enable_auto_reset: bool capture_method: Union[str, CaptureMethodEnum] capture_device_id: int capture_device_name: str @@ -34,33 +35,44 @@ class UserProfileDict(TypedDict): default_delay_time: int default_pause_time: float loop_splits: bool - split_image_directory: str captured_window_title: str capture_region: Region -DEFAULT_PROFILE = UserProfileDict( - split_hotkey="", - reset_hotkey="", - undo_split_hotkey="", - skip_split_hotkey="", - pause_hotkey="", - disable_auto_reset_image_hotkey="", - fps_limit=60, - live_capture_region=True, - capture_method=CAPTURE_METHODS.get_method_by_index(0), - capture_device_id=0, - capture_device_name="", - default_comparison_method=0, - default_similarity_threshold=0.95, - default_delay_time=0, - default_pause_time=10, - loop_splits=False, - split_image_directory="", - captured_window_title="", - capture_region=Region(x=0, y=0, width=1, height=1), -) +def get_default_settings_from_ui(autosplit: AutoSplit): + temp_dialog = QtWidgets.QDialog() + default_settings_dialog = settings_ui.Ui_DialogSettings() + default_settings_dialog.setupUi(temp_dialog) + default_settings: UserProfileDict = { + "split_hotkey": default_settings_dialog.split_input.text(), + "reset_hotkey": default_settings_dialog.reset_input.text(), + "undo_split_hotkey": default_settings_dialog.undo_split_input.text(), + "skip_split_hotkey": default_settings_dialog.skip_split_input.text(), + "pause_hotkey": default_settings_dialog.pause_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(), + "enable_auto_reset": default_settings_dialog.enable_auto_reset_checkbox.isChecked(), + "capture_method": CAPTURE_METHODS.get_method_by_index( + default_settings_dialog.capture_method_combobox.currentIndex()), + "capture_device_id": default_settings_dialog.capture_device_combobox.currentIndex(), + "capture_device_name": "", + "default_comparison_method": default_settings_dialog.default_comparison_method.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(), + "split_image_directory": autosplit.split_image_folder_input.text(), + "captured_window_title": "", + "capture_region": { + "x": autosplit.x_spinbox.value(), + "y": autosplit.y_spinbox.value(), + "width": autosplit.width_spinbox.value(), + "height": autosplit.height_spinbox.value(), + }} + del temp_dialog + return default_settings def have_settings_changed(autosplit: AutoSplit): @@ -113,7 +125,7 @@ def __load_settings_from_file(autosplit: AutoSplit, load_settings_file_path: str # 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 = cast(UserProfileDict, { - **DEFAULT_PROFILE, + **autosplit.DEFAULT_PROFILE, **toml.load(file), }) # TODO: Data Validation / fallbacks ? diff --git a/src/utils.py b/src/utils.py index 9de79fdd..c15dcf4b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -10,13 +10,13 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast import cv2 -from typing_extensions import ParamSpec, TypeGuard from win32 import win32gui from gen.build_number import AUTOSPLIT_BUILD_NUMBER if TYPE_CHECKING: - from typing_extensions import TypeGuard + from typing_extensions import ParamSpec, TypeGuard + P = ParamSpec("P") DWMWA_EXTENDED_FRAME_BOUNDS = 9 @@ -83,9 +83,6 @@ def get_or_create_eventloop(): return asyncio.get_event_loop() -P = ParamSpec("P") - - def fire_and_forget(func: Callable[P, Any]) -> Callable[P, asyncio.Future[None]]: """ Runs synchronous function asynchronously without waiting for a response.