diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 62d39ad2..0fe853ba 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -45,7 +45,7 @@ jobs: - run: scripts/install.ps1 shell: pwsh - name: Analysing the code with Pyright - run: pyright --warnings + run: pyright src/ --warnings Pylint: runs-on: windows-latest strategy: @@ -64,7 +64,7 @@ jobs: - run: scripts/install.ps1 shell: pwsh - name: Analysing the code with Pylint - run: pylint --reports=y --output-format=colorized src/ + run: pylint src/ --reports=y --output-format=colorized Flake8: runs-on: windows-latest strategy: @@ -83,7 +83,7 @@ jobs: - run: scripts/install.ps1 shell: pwsh - name: Analysing the code with Flake8 - run: flake8 + run: flake8 src/ typings/ Bandit: runs-on: windows-latest steps: @@ -98,7 +98,7 @@ jobs: - run: scripts/install.ps1 shell: pwsh - name: Analysing the code with Bandit - run: bandit -n 1 --severity-level medium --recursive src + run: bandit src/ -n 1 --severity-level medium --recursive Build: runs-on: windows-latest strategy: diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 00000000..79132012 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1 @@ +sonar.python.version=3.9, 3.10 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e6b47305..33859d80 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -9,9 +9,10 @@ "ms-python.vscode-pylance", "ms-vscode.powershell", "pkief.material-icon-theme", + "redhat.vscode-xml", "redhat.vscode-yaml", "shardulm94.trailing-spaces", - "sonarsource.sonarlint-vscode" + "sonarsource.sonarlint-vscode", ], "unwantedRecommendations": [ // Must disable in this workspace // @@ -36,5 +37,5 @@ "johnstoncode.svn-scm", // Prefer using VSCode itself as a text editor "vscodevim.vim", - ] + ], } diff --git a/.vscode/settings.json b/.vscode/settings.json index 601ce64c..ab483d74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -106,4 +106,6 @@ "powershell.codeFormatting.useCorrectCasing": true, "powershell.codeFormatting.whitespaceBetweenParameters": true, "powershell.integratedConsole.showOnStartup": false, + "xml.codeLens.enabled": true, + "xml.format.spaceBeforeEmptyCloseTag": false, } diff --git a/res/design.ui b/res/design.ui index a34632df..03aa78f7 100644 --- a/res/design.ui +++ b/res/design.ui @@ -10,12 +10,6 @@ 424 - - - 0 - 0 - - 777 diff --git a/res/settings.ui b/res/settings.ui index 4f5cc6eb..ecc0a9d7 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -1,7 +1,7 @@ - DialogSettings - + SettingsWidget + 0 @@ -10,12 +10,6 @@ 661 - - - 0 - 0 - - 291 @@ -40,12 +34,6 @@ :/resources/icon.ico:/resources/icon.ico - - false - - - false - @@ -352,7 +340,7 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 6 - 193 + 190 261 61 @@ -364,7 +352,10 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i - <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/{GITHUB_REPOSITORY}#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> + <html><head/><body><p>Image settings and flags can be set per image through the image file name. These will override the default values. View the <a href="https://github.com/{GITHUB_REPOSITORY}#readme"><span style="text-decoration: underline; color:#0000ff;">README</span></a> for full details on all available custom image settings.</p></body></html> + + + true @@ -399,6 +390,34 @@ It is highly recommended to NOT use pHash if you use masked images. It is very i 999999999 + + + + 140 + 210 + 71 + 31 + + + + + 8 + true + + + + README + + + + 0 + 0 + + + + This is a workaround because custom_image_settings_info_label simply will not open links with a left click no matter what we tried. + + @@ -714,11 +733,11 @@ reset image - set_split_hotkey_button - set_reset_hotkey_button - set_undo_split_hotkey_button - set_skip_split_hotkey_button - set_pause_hotkey_button + split_input + reset_input + undo_split_input + skip_split_input + pause_input fps_limit_spinbox live_capture_region_checkbox capture_method_combobox diff --git a/res/update_checker.ui b/res/update_checker.ui index 53567fc1..75fbc852 100644 --- a/res/update_checker.ui +++ b/res/update_checker.ui @@ -13,12 +13,6 @@ 133 - - - 0 - 0 - - 313 diff --git a/scripts/lint.ps1 b/scripts/lint.ps1 index f5931602..8466ea2e 100644 --- a/scripts/lint.ps1 +++ b/scripts/lint.ps1 @@ -2,9 +2,13 @@ $originalDirectory = $pwd Set-Location "$PSScriptRoot/.." $exitCodes = 0 +Write-Host "`nRunning autofixes..." +isort src/ typings/ +autopep8 src/ typings/ --in-place + Write-Host "`nRunning Pyright..." $Env:PYRIGHT_PYTHON_FORCE_VERSION = 'latest' -pyright --warnings +pyright src/ --warnings $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Pyright failed ($LastExitCode)" -ForegroundColor Red @@ -14,7 +18,7 @@ else { } Write-Host "`nRunning Pylint..." -pylint --output-format=colorized src/ +pylint src/ --output-format=colorized $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Pylint failed ($LastExitCode)" -ForegroundColor Red @@ -24,7 +28,7 @@ else { } Write-Host "`nRunning Flake8..." -flake8 +flake8 src/ typings/ $exitCodes += $LastExitCode if ($LastExitCode -gt 0) { Write-Host "`Flake8 failed ($LastExitCode)" -ForegroundColor Red @@ -34,7 +38,7 @@ else { } Write-Host "`nRunning Bandit..." -bandit -f custom --silent --recursive src +bandit src/ -f custom --silent --recursive # $exitCodes += $LastExitCode # Returns 1 on low if ($LastExitCode -gt 0) { Write-Host "`Bandit warning ($LastExitCode)" -ForegroundColor Yellow diff --git a/scripts/requirements-dev.txt b/scripts/requirements-dev.txt index 99f7134e..bcb5e46d 100644 --- a/scripts/requirements-dev.txt +++ b/scripts/requirements-dev.txt @@ -19,7 +19,7 @@ flake8-pyi>=22.8.1 # flake8 5 support flake8-quotes flake8-simplify pep8-naming -pylint>=2.13.9 # Respect ignore configuration options with --recursive=y +pylint>=2.13.9,<3.0.0 # Respect ignore configuration options with --recursive=y # 3.0 still in pre-release pyright # # Run `./scripts/designer.ps1` to quickly open the bundled PyQt Designer. diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 00d56180..ef80a6a1 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -13,7 +13,7 @@ numpy>=1.23 # Updated types opencv-python-headless>=4.5.4,<4.6 # https://github.com/pyinstaller/pyinstaller/issues/6889 PyQt6>=6.2.1 # Python 3.10 support -git+https://github.com/Avasam/imagehash.git@patch-2#egg=ImageHash # Contains type information + setup as package not module +git+https://github.com/JohannesBuchner/imagehash.git#egg=ImageHash # Contains type information + setup as package not module keyboard packaging Pillow diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 0a98c6f8..dc5af4cd 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 @@ -21,7 +20,7 @@ import user_profile from AutoControlledWorker import AutoControlledWorker from AutoSplitImage import COMPARISON_RESIZE, START_KEYWORD, AutoSplitImage, ImageType -from capture_method import CaptureMethodEnum, CaptureMethodInterface +from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker from hotkeys import HOTKEYS, after_setting_hotkey, send_command from menu_bar import (about_qt, about_qt_for_python, check_for_updates, get_default_settings_from_ui, open_about, @@ -64,15 +63,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): AboutWidget: about.Ui_AboutAutoSplitWidget | None = None UpdateCheckerWidget: update_checker.Ui_UpdateChecker | None = None CheckForUpdatesThread: QtCore.QThread | None = None - SettingsWidget: settings.Ui_DialogSettings | None = 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: Callable[[], None] | None = None - reset_hotkey: Callable[[], None] | None = None - skip_split_hotkey: Callable[[], None] | None = None - undo_split_hotkey: Callable[[], None] | None = None - pause_hotkey: Callable[[], None] | None = None + SettingsWidget: settings.Ui_SettingsWidget | None = None # Initialize a few attributes hwnd = 0 @@ -82,7 +73,7 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): split_image_number = 0 split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] split_groups: list[list[int]] = [] - capture_method = CaptureMethodInterface() + capture_method = CaptureMethodBase() # Last loaded settings empty and last successful loaded settings file path to None until we try to load them last_loaded_settings = DEFAULT_PROFILE @@ -123,6 +114,10 @@ def __init__(self, parent: QWidget | None = None): # pylint: disable=too-many-s 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) @@ -450,7 +445,7 @@ def skip_split(self, navigate_image_only: bool = False): # or Splitting/skipping when there are no images left if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT \ or "Delayed Split" in self.current_split_image.text() \ - or (not self.skip_split_button.isEnabled() and not self.is_auto_controlled) \ + or not (self.skip_split_button.isEnabled() or self.is_auto_controlled or navigate_image_only) \ or self.__is_current_split_out_of_range(): return @@ -795,6 +790,10 @@ def __reset_if_should(self, capture: cv2.Mat | None): self.reset() else: self.table_reset_image_live_label.setText("disabled") + else: + self.table_reset_image_live_label.setText("N/A") + self.table_reset_image_threshold_label.setText("N/A") + self.table_reset_image_highest_label.setText("N/A") return self.__check_for_reset_state_update_ui() diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 23d2a30f..e7ddabdc 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -11,7 +11,7 @@ import win32ui from win32 import win32gui -from capture_method.interface import CaptureMethodInterface +from capture_method.CaptureMethodBase import CaptureMethodBase from utils import get_window_bounds, is_valid_hwnd if TYPE_CHECKING: @@ -21,7 +21,7 @@ PW_RENDERFULLCONTENT = 0x00000002 -class BitBltCaptureMethod(CaptureMethodInterface): +class BitBltCaptureMethod(CaptureMethodBase): _render_full_content = False def get_frame(self, autosplit: AutoSplit) -> tuple[cv2.Mat | None, bool]: @@ -57,15 +57,11 @@ def get_frame(self, autosplit: AutoSplit) -> tuple[cv2.Mat | None, bool]: image.shape = (selection["height"], selection["width"], 4) 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()) - except win32ui.error: - pass + # Cleanup DC and handle + dc_object.DeleteDC() + compatible_dc.DeleteDC() + win32gui.ReleaseDC(hwnd, window_dc) + win32gui.DeleteObject(bitmap.GetHandle()) return image, False def recover_window(self, captured_window_title: str, autosplit: AutoSplit): diff --git a/src/capture_method/interface.py b/src/capture_method/CaptureMethodBase.py similarity index 97% rename from src/capture_method/interface.py rename to src/capture_method/CaptureMethodBase.py index 253eb6cc..3f6d1212 100644 --- a/src/capture_method/interface.py +++ b/src/capture_method/CaptureMethodBase.py @@ -12,7 +12,7 @@ # pylint: disable=no-self-use,unnecessary-dunder-call -class CaptureMethodInterface(): +class CaptureMethodBase(): def __init__(self, autosplit: AutoSplit | None = None): pass diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index 612697ff..20e2fb18 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -5,7 +5,7 @@ import cv2 -from capture_method.interface import CaptureMethodInterface +from capture_method.CaptureMethodBase import CaptureMethodBase from error_messages import CREATE_NEW_ISSUE_MESSAGE, exception_traceback from utils import is_valid_image @@ -13,7 +13,7 @@ from AutoSplit import AutoSplit -class VideoCaptureDeviceCaptureMethod(CaptureMethodInterface): +class VideoCaptureDeviceCaptureMethod(CaptureMethodBase): capture_device: cv2.VideoCapture capture_thread: Thread | None last_captured_frame: cv2.Mat | None = None diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index 8a687078..7fca63d8 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -13,7 +13,7 @@ from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap from winsdk.windows.media.capture import MediaCapture -from capture_method.interface import CaptureMethodInterface +from capture_method.CaptureMethodBase import CaptureMethodBase from utils import WINDOWS_BUILD_NUMBER, is_valid_hwnd if TYPE_CHECKING: @@ -22,7 +22,7 @@ WGC_NO_BORDER_MIN_BUILD = 20348 -class WindowsGraphicsCaptureMethod(CaptureMethodInterface): +class WindowsGraphicsCaptureMethod(CaptureMethodBase): size: SizeInt32 frame_pool: Direct3D11CaptureFramePool | None = None session: GraphicsCaptureSession | None = None diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 8a6cd28f..2957bef2 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -6,14 +6,13 @@ from enum import Enum, EnumMeta, unique from typing import TYPE_CHECKING, TypedDict -import cv2 from pygrabber import dshow_graph from winsdk.windows.media.capture import MediaCapture from capture_method.BitBltCaptureMethod import BitBltCaptureMethod +from capture_method.CaptureMethodBase import CaptureMethodBase from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod -from capture_method.interface import CaptureMethodInterface from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod from utils import WINDOWS_BUILD_NUMBER @@ -37,7 +36,7 @@ class CaptureMethodInfo(): name: str short_description: str description: str - implementation: type[CaptureMethodInterface] + implementation: type[CaptureMethodBase] class CaptureMethodMeta(EnumMeta): @@ -102,7 +101,7 @@ def __getitem__(self, key: CaptureMethodEnum): name="None", short_description="", description="", - implementation=CaptureMethodInterface + implementation=CaptureMethodBase ) CAPTURE_METHODS = CaptureMethodDict({ @@ -209,19 +208,23 @@ async def get_all_video_capture_devices() -> list[CameraInfo]: named_video_inputs = dshow_graph.FilterGraph().get_input_devices() async def get_camera_info(index: int, device_name: str): - video_capture = cv2.VideoCapture(index) - video_capture.setExceptionMode(True) backend = "" - try: - # https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d - 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 - finally: - video_capture.release() + # Probing freezes some devices (like GV-USB2 and AverMedia) if already in use + # https://github.com/Toufool/Auto-Split/issues/169 + # FIXME: Maybe offer the option to the user to obtain more info about their devies? + # Off by default. With a tooltip to explain the risk. + # video_capture = cv2.VideoCapture(index) + # video_capture.setExceptionMode(True) + # try: + # # https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d + # 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 + # finally: + # video_capture.release() return CameraInfo(index, device_name, False, backend) future = asyncio.gather(*[ diff --git a/src/hotkeys.py b/src/hotkeys.py index a7aff538..fb23600a 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -225,7 +225,7 @@ def is_valid_hotkey_name(hotkey_name: str): def set_hotkey(autosplit: AutoSplit, hotkey: Hotkey, preselected_hotkey_name: str = ""): if autosplit.SettingsWidget: # Unfocus all fields - cast(QtWidgets.QDialog, autosplit.SettingsWidget).setFocus() + cast(QtWidgets.QWidget, 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 37dd5602..8cbcb36c 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -118,7 +118,7 @@ def get_capture_method_index(capture_method: str | CaptureMethodEnum): return 0 -class __SettingsWidget(QtWidgets.QDialog, settings_ui.Ui_DialogSettings): +class __SettingsWidget(QtWidgets.QWidget, settings_ui.Ui_SettingsWidget): __video_capture_devices: list[CameraInfo] = [] """ Used to temporarily store the existing cameras, @@ -180,10 +180,7 @@ def __set_all_capture_devices(self): else: self.capture_device_combobox.setPlaceholderText("No device found.") - def __init__(self, autosplit: AutoSplit): - super().__init__() - self.setupUi(self) - self.autosplit = autosplit + def __apply_os_specific_ui_fixes(self): # 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 @@ -192,13 +189,27 @@ 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) - # Don't autofocus any particular field - self.setFocus() + def __set_readme_link(self): self.custom_image_settings_info_label.setText( self.custom_image_settings_info_label .text() .format(GITHUB_REPOSITORY=GITHUB_REPOSITORY)) + # HACK: This is a workaround because custom_image_settings_info_label + # simply will not open links with a left click no matter what we tried. + self.readme_link_button.clicked.connect( + lambda: webbrowser.open(f"https://github.com/{GITHUB_REPOSITORY}#readme")) + self.readme_link_button.setStyleSheet("border: 0px; background-color:rgba(0,0,0,0%);") + + def __init__(self, autosplit: AutoSplit): + super().__init__() + self.setupUi(self) + self.autosplit = autosplit + self.__apply_os_specific_ui_fixes() + self.__set_readme_link() + # Don't autofocus any particular field + self.setFocus() + # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() @@ -292,13 +303,13 @@ def hotkey_connect(hotkey: Hotkey): def open_settings(autosplit: AutoSplit): - if not autosplit.SettingsWidget or cast(QtWidgets.QDialog, autosplit.SettingsWidget).isHidden(): + if not autosplit.SettingsWidget or cast(QtWidgets.QWidget, autosplit.SettingsWidget).isHidden(): autosplit.SettingsWidget = __SettingsWidget(autosplit) def get_default_settings_from_ui(autosplit: AutoSplit): - temp_dialog = QtWidgets.QDialog() - default_settings_dialog = settings_ui.Ui_DialogSettings() + temp_dialog = QtWidgets.QWidget() + default_settings_dialog = settings_ui.Ui_SettingsWidget() default_settings_dialog.setupUi(temp_dialog) default_settings: user_profile.UserProfileDict = { "split_hotkey": default_settings_dialog.split_input.text(), diff --git a/src/utils.py b/src/utils.py index b4147944..7f6547c7 100644 --- a/src/utils.py +++ b/src/utils.py @@ -102,9 +102,8 @@ def wrapped(*args: Any, **kwargs: Any): """The directory of either AutoSplit.exe or AutoSplit.py""" # Shared strings -# 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 +# Set AUTOSPLIT_BUILD_NUMBER to an empty string to generate a clean version number +# AUTOSPLIT_BUILD_NUMBER = "" # pyright: ignore[reportConstantRedefinition] # noqa: F811 +AUTOSPLIT_VERSION = "2.0.0-alpha.5" + (f"-{AUTOSPLIT_BUILD_NUMBER}" if AUTOSPLIT_BUILD_NUMBER else "") START_AUTO_SPLITTER_TEXT = "Start Auto Splitter" GITHUB_REPOSITORY = AUTOSPLIT_GITHUB_REPOSITORY