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/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml
index 6630a9c7..2511e920 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: # Allows manual builds
push:
branches:
- main
@@ -37,13 +38,9 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- cache-dependency-path: 'scripts/requirements-dev.txt'
- - name: Install dependencies
- run: |
- pip install -r "scripts/requirements-dev.txt"
- npm install -g pyright
- npm list -g pyright
- - run: scripts/compile_resources.bat
+ cache-dependency-path: 'scripts/requirements*.txt'
+ - run: scripts/install.ps1
+ shell: pwsh
- name: Analysing the code with Pyright
run: pyright --warnings
Pylint:
@@ -60,10 +57,9 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- cache-dependency-path: 'scripts/requirements-dev.txt'
- - name: Install dependencies
- run: pip install -r "scripts/requirements-dev.txt"
- - run: scripts/compile_resources.bat
+ 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:
@@ -80,30 +76,26 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- cache-dependency-path: 'scripts/requirements-dev.txt'
- - name: Install dependencies
- run: pip install -r "scripts/requirements-dev.txt"
- - run: scripts/compile_resources.bat
+ 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'
- - name: Install dependencies
- run: pip install -r "scripts/requirements-dev.txt"
- - run: scripts/compile_resources.bat
+ 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:
@@ -111,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
@@ -120,11 +112,22 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- - name: Install dependencies
- run: pip install -r "scripts/requirements.txt"
- - run: scripts/build.bat
+ - 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/.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/.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/settings.json b/.vscode/settings.json
index a4a19fd0..18f24f81 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -53,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,
@@ -77,4 +81,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..69828ccd 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -1,14 +1,19 @@
{
"version": "2.0.0",
"tasks": [
+ {
+ "label": "Compile resources",
+ "type": "shell",
+ "command": "scripts/compile_resources.ps1"
+ },
{
"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..3aa353a1 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://sonarcloud.io/dashboard?id=Avasam_Auto-Split)
[](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split)
[](https://sonarcloud.io/summary/new_code?id=Avasam_Auto-Split)
-[](https://semver.org/)
+[](https://semver.org/)
Easy to use image comparison based auto splitter for speedrunning on console or PC.
@@ -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
@@ -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.
@@ -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 carets `^^`:
+ - `^0^`: L2 Norm
+ - `^1^`: Histogram
+ - `^2^`: Perceptual Hash
- Image loop amounts are placed between at symbols `@@` in the filename. For example, a specific image that you want to split 5 times in a row would be `@5@`. The current loop # is conveniently located beneath the current split image.
- Flags are placed between curly brackets `{}` in the filename. Multiple flags are placed in the same set of curly brackets. Current available flags:
- - {d} dummy split image. When matched, it moves to the next image without hitting your split hotkey.
- - {b} split when similarity goes below the threshold rather than above. When a split image filename has this flag, the split image similarity will go above the threshold, do nothing, and then split the next time the similarity goes below the threshold.
- - {p} pause flag. When a split image filename has this flag, it will hit your pause hotkey rather than your split hokey.
+ - `{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.
diff --git a/pyproject.toml b/pyproject.toml
index e35ac2b2..1282956d 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
@@ -128,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/res/about.ui b/res/about.ui
index 4cb2cc3d..05e4914a 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,7 +124,13 @@ consider donating. Thank you!
:/resources/icon.ico
+
+ true
+
+ icon_label
+ version_label
+ created_by_label
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..e670fa3b 100644
--- a/res/settings.ui
+++ b/res/settings.ui
@@ -6,8 +6,8 @@
0
0
- 289
- 621
+ 291
+ 661
@@ -18,22 +18,28 @@
- 289
- 621
+ 291
+ 661
- 289
- 621
+ 291
+ 661
-
- ArrowCursor
+
+
+ 9
+
Settings
+
+
+ :/resources/icon.ico:/resources/icon.ico
+
false
@@ -44,7 +50,7 @@
10
- 180
+ 200
271
181
@@ -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:
@@ -175,9 +175,9 @@
10
- 370
+ 390
271
- 241
+ 261
@@ -196,16 +196,27 @@
167
- 26
+ 25
88
22
-
-
-
-
+ 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.
-
@@ -227,7 +238,7 @@
6
- 29
+ 28
161
16
@@ -240,14 +251,11 @@
6
- 117
+ 118
161
16
-
-
-
Default Pause Time (sec):
@@ -256,7 +264,7 @@
167
- 114
+ 115
87
22
@@ -289,12 +297,6 @@
16
-
-
-
-
-
-
Default Similarity Threshold:
@@ -303,7 +305,7 @@
167
- 56
+ 55
52
22
@@ -331,7 +333,7 @@
6
- 144
+ 143
235
20
@@ -350,7 +352,7 @@
6
- 173
+ 193
261
61
@@ -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.
@@ -409,6 +399,22 @@
999999999
+
+
+
+ 6
+ 168
+ 151
+ 20
+
+
+
+ Enable auto reset image
+
+
+ true
+
+
@@ -416,7 +422,7 @@
10
10
271
- 161
+ 191
@@ -448,9 +454,6 @@
-
- true
-
76
@@ -475,9 +478,6 @@
20
-
- Qt::StrongFocus
-
@@ -507,9 +507,6 @@
20
-
- IBeamCursor
-
@@ -566,7 +563,7 @@
180
- 28
+ 30
81
21
@@ -600,9 +597,6 @@
20
-
- Qt::StrongFocus
-
@@ -668,20 +662,69 @@
true
+
+
+
+ 180
+ 155
+ 81
+ 21
+
+
+
+ Qt::NoFocus
+
+
+ Set Hotkey
+
+
+
+
+
+ 6
+ 154
+ 71
+ 31
+
+
+
+ Toggle auto
+reset image
+
+
+
+
+
+ 76
+ 155
+ 94
+ 20
+
+
+
+
+
+
+ true
+
+
- 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/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..b9b3e387
--- /dev/null
+++ b/scripts/build.ps1
@@ -0,0 +1,9 @@
+& "$PSScriptRoot/compile_resources.ps1"
+
+pyinstaller `
+ --windowed `
+ --onefile `
+ --additional-hooks-dir=Pyinstaller/hooks `
+ --icon=res/icon.ico `
+ --splash=res/splash.png `
+ "$PSScriptRoot/../src/AutoSplit.py"
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..a2369324
--- /dev/null
+++ b/scripts/compile_resources.ps1
@@ -0,0 +1,16 @@
+$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'
+
+$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`""
+
+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..bf6123f8
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,16 @@
+# Installing Python dependencies
+$dev = If ($env:GITHUB_JOB -eq 'Build') { '' } Else { '-dev' }
+pip install wheel --upgrade
+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) {
+ Write-Host "`n"
+ & "$PSScriptRoot/compile_resources.ps1"
+}
+
+# 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
+}
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-dev.txt b/scripts/requirements-dev.txt
index 9249c181..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
-pywin32-stubs>=0.1.6
+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 263c7a8d..314dfb8f 100644
--- a/scripts/requirements.txt
+++ b/scripts/requirements.txt
@@ -2,15 +2,15 @@
#
# 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
+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
@@ -19,14 +19,15 @@ keyboard
packaging
Pillow
pyautogui
-pywin32>=301
requests
certifi
toml
-winsdk>=v1.0.0b4
+psutil
pygrabber
+# Windows-only
+pywin32>=301
+winsdk>=v1.0.0b4
git+https://github.com/ranchen421/D3DShot.git#egg=D3DShot # https://github.com/SerpentAI/D3DShot/issues/44
-typing-extensions
#
# 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
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 01198571..dba0bcd9 100644
--- a/src/AutoSplit.py
+++ b/src/AutoSplit.py
@@ -2,40 +2,34 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
+import asyncio
import ctypes
import os
import signal
import sys
-from collections.abc import Callable
from time import time
from types import FunctionType
from typing import Optional
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, 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 auto_control import start_auto_control_loop
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 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)
-from WindowsGraphicsCapture import WindowsGraphicsCapture
CHECK_FPS_ITERATIONS = 10
@@ -43,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,31 +63,16 @@ 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
- # 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"""
- 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]] = []
-
- # 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"."""
+ capture_method = CaptureMethodInterface()
# Automatic timer start
highest_similarity = 0.0
@@ -108,8 +87,7 @@ 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
+ auto_control_loop: Optional[asyncio.Future[None]] = None
def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-many-statements
super().__init__(parent)
@@ -129,25 +107,21 @@ def __init__(self, parent: Optional[QWidget] = None): # pylint: disable=too-man
self.width_spinbox.setFrame(False)
self.height_spinbox.setFrame(False)
- # Get default values defined in SettingsDialog
- self.settings_dict = get_default_settings_from_ui(self)
+ # hotkeys need to be initialized to be passed as thread arguments in hotkeys.py
+ for hotkey in HOTKEYS:
+ setattr(self, f"{hotkey}_hotkey", None)
+
+ # 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)
@@ -156,15 +130,20 @@ 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")
+ # 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))
@@ -193,8 +172,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)
@@ -243,15 +220,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 +356,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
@@ -702,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)
@@ -730,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)
@@ -748,31 +729,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)
@@ -784,25 +753,28 @@ 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:
- 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.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()
@@ -817,7 +789,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)))
@@ -839,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:
@@ -848,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:
@@ -880,15 +852,54 @@ 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"
+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/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/auto_control.py b/src/auto_control.py
new file mode 100644
index 00000000..c1fab203
--- /dev/null
+++ b/src/auto_control.py
@@ -0,0 +1,44 @@
+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):
+ 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/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py
new file mode 100644
index 00000000..f594ca3b
--- /dev/null
+++ b/src/capture_method/BitBltCaptureMethod.py
@@ -0,0 +1,76 @@
+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, is_valid_hwnd
+
+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 get_frame(self, autosplit: AutoSplit) -> tuple[Optional[cv2.Mat], bool]:
+ 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)
+ 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
+ return image, False
+
+ def recover_window(self, captured_window_title: str, autosplit: AutoSplit):
+ hwnd = win32gui.FindWindow(None, captured_window_title)
+ if not is_valid_hwnd(hwnd):
+ return False
+ autosplit.hwnd = hwnd
+ return self.check_selected_region_exists(autosplit)
diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py
new file mode 100644
index 00000000..6a9234e8
--- /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 self.check_selected_region_exists(autosplit):
+ 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..bdb4ed2d
--- /dev/null
+++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+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):
+ 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():
+ 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
+ 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))
+
+ 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
+
+ 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)
+ image = image[
+ y:y + selection["height"],
+ x:x + selection["width"],
+ ]
+ 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/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py
new file mode 100644
index 00000000..04fd41d1
--- /dev/null
+++ b/src/capture_method/WindowsGraphicsCaptureMethod.py
@@ -0,0 +1,145 @@
+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, is_valid_hwnd
+
+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 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
+ 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)
+ # Only needed for the type-checker
+ and self.frame_pool):
+ return None, False
+
+ try:
+ frame = self.frame_pool.try_get_next_frame()
+ # 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
+
+ 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)
+ if not is_valid_hwnd(hwnd):
+ 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(
+ is_valid_hwnd(autosplit.hwnd)
+ and self.frame_pool
+ and self.session)
diff --git a/src/CaptureMethod.py b/src/capture_method/__init__.py
similarity index 58%
rename from src/CaptureMethod.py
rename to src/capture_method/__init__.py
index e8f28ace..820bfe4c 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, Union, cast
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
@@ -56,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"
@@ -63,15 +74,53 @@ def __hash__(self):
VIDEO_CAPTURE_DEVICE = "VIDEO_CAPTURE_DEVICE"
-class DisplayCaptureMethodDict(OrderedDict[CaptureMethod, DisplayCaptureMethodInfo]):
+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:
return next(iter(self))
return list(self.keys())[index]
-
-CAPTURE_METHODS = DisplayCaptureMethodDict({
- CaptureMethod.BITBLT: DisplayCaptureMethodInfo(
+ 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(
name="BitBlt",
short_description="fastest, least compatible",
description=(
@@ -79,8 +128,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 +142,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 +153,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,10 +164,11 @@ 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",
+ short_description="see below",
description=(
"\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. "
"\nYou can select one below. "
@@ -122,17 +176,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
@@ -143,7 +219,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):
@@ -152,11 +228,11 @@ 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 == 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..d1646fd2
--- /dev/null
+++ b/src/capture_method/interface.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Optional
+
+import cv2
+
+from utils import is_valid_hwnd
+
+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
+ """
+ return None, False
+
+ def recover_window(self, captured_window_title: str, autosplit: AutoSplit) -> bool:
+ return False
+
+ def check_selected_region_exists(self, autosplit: AutoSplit) -> bool:
+ return is_valid_hwnd(autosplit.hwnd)
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
diff --git a/src/error_messages.py b/src/error_messages.py
index 1745d430..f7fdd51c 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
@@ -20,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():
@@ -98,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():
@@ -119,10 +126,12 @@ def stdin_lost():
set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.")
-def exception_traceback(message: str, exception: BaseException):
+def already_running():
set_text_message(
- message,
- "\n".join(traceback.format_exception(None, exception, exception.__traceback__)))
+ "An instance of AutoSplit is already running.
Are you sure you want to open a another one?",
+ "",
+ "Don't open",
+ "Ignore")
CREATE_NEW_ISSUE_MESSAGE = (
@@ -131,6 +140,16 @@ def exception_traceback(message: str, exception: BaseException):
+ "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
@@ -143,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 154bc228..0201a1c9 100644
--- a/src/hotkeys.py
+++ b/src/hotkeys.py
@@ -1,13 +1,13 @@
from __future__ import annotations
-import threading
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, is_digit
+from utils import START_AUTO_SPLITTER_TEXT, fire_and_forget, is_digit
if TYPE_CHECKING:
from AutoSplit import AutoSplit
@@ -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", "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,6 +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 == "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
@@ -210,6 +217,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
@@ -217,6 +226,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 +258,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()
+ callback()
diff --git a/src/menu_bar.py b/src/menu_bar.py
index 4b0c510e..c0f55d51 100644
--- a/src/menu_bar.py
+++ b/src/menu_bar.py
@@ -1,25 +1,21 @@
from __future__ import annotations
import asyncio
-import threading
import webbrowser
-from typing import TYPE_CHECKING, Any, Optional, Union, cast
+from typing import TYPE_CHECKING, Any, 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 gen import about, design, resources_rc, settings as settings_ui, update_checker # noqa: F401
+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
+from utils import AUTOSPLIT_VERSION, FIRST_WIN_11_BUILD, WINDOWS_BUILD_NUMBER, decimal, fire_and_forget
if TYPE_CHECKING:
from AutoSplit import AutoSplit
@@ -52,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.")
@@ -79,35 +76,18 @@ 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, CaptureMethod]):
- """
- Returns 0 if the capture_method is invalid or unsupported
- """
try:
- return list(CAPTURE_METHODS.keys()).index(cast(CaptureMethod, 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)
+ 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):
@@ -142,58 +122,44 @@ 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
-
- async def __set_all_capture_devices(self):
- self.__video_capture_devices = await 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} [{device.backend}]{' (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.")
+ 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)
+
+ @fire_and_forget
+ def __set_all_capture_devices(self):
+ 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__()
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
@@ -202,11 +168,12 @@ 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()
- threading.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
@@ -217,7 +184,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)
@@ -237,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)
@@ -246,7 +213,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"])
@@ -254,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
@@ -266,9 +234,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(
@@ -285,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()
@@ -292,36 +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(),
- "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/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..b8d62a65 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_hwnd, 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()))
@@ -96,17 +90,13 @@ 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
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)
@@ -132,17 +122,13 @@ 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
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 +160,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 +184,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 +268,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/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
diff --git a/src/user_profile.py b/src/user_profile.py
index 7adf624c..8a79e994 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 gen import design
+from capture_method import CAPTURE_METHODS, CaptureMethodEnum, Region, change_capture_method
+from gen import design, settings as settings_ui
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:
@@ -28,9 +23,11 @@ class UserProfileDict(TypedDict):
undo_split_hotkey: str
skip_split_hotkey: str
pause_hotkey: str
+ toggle_auto_reset_image_hotkey: str
fps_limit: int
live_capture_region: bool
- capture_method: Union[str, CaptureMethod]
+ enable_auto_reset: bool
+ capture_method: Union[str, CaptureMethodEnum]
capture_device_id: int
capture_device_name: str
default_comparison_method: int
@@ -38,32 +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="",
- 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):
@@ -116,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 ?
@@ -132,30 +141,19 @@ 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)
+ 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")
+
return True
@@ -179,12 +177,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]))
diff --git a/src/utils.py b/src/utils.py
index 5bf75eaa..c15dcf4b 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -1,12 +1,24 @@
+from __future__ import annotations
+
import asyncio
+import ctypes
+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
+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 ParamSpec, TypeGuard
+ P = ParamSpec("P")
+
+DWMWA_EXTENDED_FRAME_BOUNDS = 9
def decimal(value: Union[int, float]):
@@ -29,9 +41,57 @@ def is_valid_image(image: Optional[cv2.Mat]) -> TypeGuard[cv2.Mat]:
return image is not None and bool(image.size)
-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)
+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":
+ return bool(win32gui.IsWindow(hwnd) and win32gui.GetWindowText(hwnd))
+ 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,
+ 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 get_or_create_eventloop():
+ try:
+ return asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ return asyncio.get_event_loop()
+
+
+def 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: P.args, **kwargs: P.kwargs):
+ return get_or_create_eventloop().run_in_executor(None, lambda: func(*args, **kwargs))
return wrapped
@@ -46,5 +106,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"
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 1638dd1c..1d84fa2d 100644
--- a/typings/cv2-stubs/__init__.pyi
+++ b/typings/cv2-stubs/__init__.pyi
@@ -1,10 +1,14 @@
# 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
+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]]
@@ -1578,7 +1582,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: ...
@@ -1586,6 +1590,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:
@@ -2233,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'
...
@@ -3109,4 +3121,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
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