diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e44d2cc..223d0444 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,7 +87,9 @@ jobs: run: | python dist/pre_release.py python -m build - echo "dvr_scan_version=`python -c \"import dvr_scan; print(dvr_scan.__version__.replace('-', '.'))\"`" >> "$GITHUB_ENV" + # Normalize to the PEP 440 form setuptools uses in sdist/wheel filenames (2.0-rc1 -> 2.0rc1, + # 2.1-dev0 -> 2.1.dev0). `packaging` is installed as a dependency of `build`. + echo "dvr_scan_version=`python -c \"import dvr_scan; from packaging.version import Version; print(Version(dvr_scan.__version__))\"`" >> "$GITHUB_ENV" - name: Build Package (Headless) shell: bash @@ -96,26 +98,39 @@ jobs: python -m build git reset --hard HEAD + # *NOTE*: Both OpenCV packages share the same on-disk cv2 package, and the install order is + # not deterministic. Put the contrib modules back so the CNT smoke test passes. + # TODO(https://github.com/Breakthrough/PySceneDetect/issues/601): Coordinate a resolution + # for this issue. + - name: Smoke Test (Source) run: | uv pip install dist/dvr_scan-${{ env.dvr_scan_version }}.tar.gz + uv pip install --reinstall --no-deps opencv-contrib-python python -m dvr_scan --version python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 + python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 -b CNT uv pip uninstall dvr-scan uv pip install dist/dvr_scan_headless-${{ env.dvr_scan_version }}.tar.gz + uv pip install --reinstall --no-deps opencv-contrib-python-headless python -m dvr_scan --version python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 + python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 -b CNT uv pip uninstall dvr-scan-headless - name: Smoke Test (Wheel) run: | uv pip install dist/dvr_scan-${{ env.dvr_scan_version }}-py3-none-any.whl + uv pip install --reinstall --no-deps opencv-contrib-python python -m dvr_scan --version python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 + python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 -b CNT uv pip uninstall dvr-scan uv pip install dist/dvr_scan_headless-${{ env.dvr_scan_version }}-py3-none-any.whl + uv pip install --reinstall --no-deps opencv-contrib-python-headless python -m dvr_scan --version python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 + python -m dvr_scan -i tests/resources/simple_movement.mp4 -so -df 4 -et 100 -b CNT uv pip uninstall dvr-scan-headless - name: Upload Package diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index 498a114f..78f25132 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -34,7 +34,7 @@ jobs: - name: Install Dependencies run: | uv pip install --upgrade pip build wheel virtualenv setuptools - uv pip install -r requirements.txt + uv pip install -e ".[dev]" - name: Download FFMPEG ${{ env.ffmpeg_version }} env: @@ -113,7 +113,7 @@ jobs: - name: Install Python Dependencies run: | uv pip install --upgrade pip build wheel virtualenv setuptools - uv pip install -r requirements.txt + uv pip install -e ".[dev]" - name: Run UI Test run: | @@ -165,7 +165,7 @@ jobs: - name: Install Python Dependencies run: | uv pip install --upgrade pip build wheel virtualenv setuptools - uv pip install -r requirements.txt + uv pip install -e ".[dev]" - name: Run UI Test run: | diff --git a/.github/workflows/test-installer.yml b/.github/workflows/test-installer.yml index 43aec01e..c3858f0a 100644 --- a/.github/workflows/test-installer.yml +++ b/.github/workflows/test-installer.yml @@ -272,6 +272,72 @@ jobs: } Write-Host 'Verified clean removal.' } + + function Get-StartMenuShortcutDir { + # SHORTCUTDIR resolves to [ProgramMenuFolder][ProductName]; the + # MSI installs per-machine on the elevated runner, but check the + # per-user Programs folder too so a misplaced install fails loudly. + foreach ($folder in 'CommonPrograms', 'Programs') { + $dir = Join-Path ([Environment]::GetFolderPath($folder)) 'DVR-Scan' + if (Test-Path $dir) { return $dir } + } + return $null + } + + # Start Menu entries (DVR-Scan.aip MsiShortsComponent): the plain + # "DVR-Scan" entry existed before 2.0; the two mode-specific entries + # launch dvr-scan-app.exe with --startup-mode wizard / advanced. + $StartMenuShortcuts = @('DVR-Scan.lnk', 'DVR-Scan Wizard.lnk', 'DVR-Scan Advanced Mode.lnk') + + function Assert-StartMenuShortcuts { + param([Parameter(Mandatory)][bool]$Installed) + $dir = Get-StartMenuShortcutDir + if ($Installed) { + if (-not $dir) { throw 'Start Menu folder DVR-Scan not found after install' } + $present = @(Get-ChildItem $dir -Filter '*.lnk' | Select-Object -ExpandProperty Name) + Write-Host "Start Menu entries in ${dir}: $($present -join ', ')" + foreach ($name in $StartMenuShortcuts) { + if ($present -notcontains $name) { throw "Start Menu shortcut '$name' missing from $dir" } + } + $shell = New-Object -ComObject WScript.Shell + $expected = @{ 'DVR-Scan Wizard.lnk' = '--startup-mode wizard'; 'DVR-Scan Advanced Mode.lnk' = '--startup-mode advanced' } + foreach ($name in $expected.Keys) { + $lnk = $shell.CreateShortcut((Join-Path $dir $name)) + if ((Split-Path $lnk.TargetPath -Leaf) -ne 'dvr-scan-app.exe') { + throw "$name targets '$($lnk.TargetPath)', expected dvr-scan-app.exe" + } + if ($lnk.Arguments.Trim() -ne $expected[$name]) { + throw "$name has arguments '$($lnk.Arguments)', expected '$($expected[$name])'" + } + } + } elseif ($dir) { + $left = @(Get-ChildItem $dir -Filter '*.lnk' | Select-Object -ExpandProperty Name) + if ($left.Count -ne 0) { throw "Start Menu shortcuts remain after uninstall: $($left -join ', ')" } + } + } + + function Get-UserConfigPath { + # The MSI installs the config template (component dvrscan.cfg, marked + # Permanent + NeverOverwrite) to LocalAppDataFolder\DVR-Scan. + Join-Path $env:LOCALAPPDATA 'DVR-Scan\dvr-scan.cfg' + } + + function Get-UserConfigHash { + $cfg = Get-UserConfigPath + if (-not (Test-Path $cfg)) { throw "user config template not installed at $cfg" } + (Get-FileHash $cfg -Algorithm SHA256).Hash + } + + function Assert-UserConfigTemplate { + # A fresh install ships a template that starts the GUI in the Scan Wizard. + $cfg = Get-UserConfigPath + if (-not (Test-Path $cfg)) { throw "user config template not installed at $cfg" } + $active = @(Get-Content $cfg | Where-Object { $_ -match '^\s*startup-mode\s*=\s*(\S+)' } | ForEach-Object { $Matches[1].ToLower() }) + if (($active -join ',') -ne 'wizard') { + throw "expected exactly one active 'startup-mode = wizard' line in $cfg, found: $($active -join ',')" + } + Write-Host "Config template at $cfg sets startup-mode = wizard." + } '@ $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' Set-Content -LiteralPath $dest -Value $helpers @@ -348,6 +414,8 @@ jobs: if (-not (Test-DirOnRegistryPath -Directory $entry.InstallLocation)) { throw "$($entry.InstallLocation) was not added to any PATH registry value" } + Assert-StartMenuShortcuts -Installed $true + Assert-UserConfigTemplate Write-Host 'Install verified.' - name: Functional smoke test @@ -370,6 +438,9 @@ jobs: run: | . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + Assert-StartMenuShortcuts -Installed $false + # The config component is Permanent: user settings survive an uninstall. + if (-not (Test-Path (Get-UserConfigPath))) { throw 'user config was removed by uninstall' } - name: Upload msiexec logs if: failure() @@ -538,6 +609,72 @@ jobs: } Write-Host 'Verified clean removal.' } + + function Get-StartMenuShortcutDir { + # SHORTCUTDIR resolves to [ProgramMenuFolder][ProductName]; the + # MSI installs per-machine on the elevated runner, but check the + # per-user Programs folder too so a misplaced install fails loudly. + foreach ($folder in 'CommonPrograms', 'Programs') { + $dir = Join-Path ([Environment]::GetFolderPath($folder)) 'DVR-Scan' + if (Test-Path $dir) { return $dir } + } + return $null + } + + # Start Menu entries (DVR-Scan.aip MsiShortsComponent): the plain + # "DVR-Scan" entry existed before 2.0; the two mode-specific entries + # launch dvr-scan-app.exe with --startup-mode wizard / advanced. + $StartMenuShortcuts = @('DVR-Scan.lnk', 'DVR-Scan Wizard.lnk', 'DVR-Scan Advanced Mode.lnk') + + function Assert-StartMenuShortcuts { + param([Parameter(Mandatory)][bool]$Installed) + $dir = Get-StartMenuShortcutDir + if ($Installed) { + if (-not $dir) { throw 'Start Menu folder DVR-Scan not found after install' } + $present = @(Get-ChildItem $dir -Filter '*.lnk' | Select-Object -ExpandProperty Name) + Write-Host "Start Menu entries in ${dir}: $($present -join ', ')" + foreach ($name in $StartMenuShortcuts) { + if ($present -notcontains $name) { throw "Start Menu shortcut '$name' missing from $dir" } + } + $shell = New-Object -ComObject WScript.Shell + $expected = @{ 'DVR-Scan Wizard.lnk' = '--startup-mode wizard'; 'DVR-Scan Advanced Mode.lnk' = '--startup-mode advanced' } + foreach ($name in $expected.Keys) { + $lnk = $shell.CreateShortcut((Join-Path $dir $name)) + if ((Split-Path $lnk.TargetPath -Leaf) -ne 'dvr-scan-app.exe') { + throw "$name targets '$($lnk.TargetPath)', expected dvr-scan-app.exe" + } + if ($lnk.Arguments.Trim() -ne $expected[$name]) { + throw "$name has arguments '$($lnk.Arguments)', expected '$($expected[$name])'" + } + } + } elseif ($dir) { + $left = @(Get-ChildItem $dir -Filter '*.lnk' | Select-Object -ExpandProperty Name) + if ($left.Count -ne 0) { throw "Start Menu shortcuts remain after uninstall: $($left -join ', ')" } + } + } + + function Get-UserConfigPath { + # The MSI installs the config template (component dvrscan.cfg, marked + # Permanent + NeverOverwrite) to LocalAppDataFolder\DVR-Scan. + Join-Path $env:LOCALAPPDATA 'DVR-Scan\dvr-scan.cfg' + } + + function Get-UserConfigHash { + $cfg = Get-UserConfigPath + if (-not (Test-Path $cfg)) { throw "user config template not installed at $cfg" } + (Get-FileHash $cfg -Algorithm SHA256).Hash + } + + function Assert-UserConfigTemplate { + # A fresh install ships a template that starts the GUI in the Scan Wizard. + $cfg = Get-UserConfigPath + if (-not (Test-Path $cfg)) { throw "user config template not installed at $cfg" } + $active = @(Get-Content $cfg | Where-Object { $_ -match '^\s*startup-mode\s*=\s*(\S+)' } | ForEach-Object { $Matches[1].ToLower() }) + if (($active -join ',') -ne 'wizard') { + throw "expected exactly one active 'startup-mode = wizard' line in $cfg, found: $($active -join ',')" + } + Write-Host "Config template at $cfg sets startup-mode = wizard." + } '@ $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' Set-Content -LiteralPath $dest -Value $helpers @@ -578,6 +715,15 @@ jobs: & $exe --version if ($LASTEXITCODE -ne 0) { throw "baseline dvr-scan --version exited with $LASTEXITCODE" } Add-Content $env:GITHUB_ENV "OLD_INSTALL_DIR=$($visible[0].InstallLocation)" + # The previous release's config template, untouched. The new template + # differs (2.0 sets startup-mode = wizard), so the upgrade must leave + # this file alone (dvrscan.cfg component is NeverOverwrite) or existing + # users would be switched to the wizard. Hashing the UNMODIFIED template + # is the strict check: MSI would otherwise overwrite an unmodified file. + $cfg = Get-UserConfigPath + if (-not (Test-Path $cfg)) { throw "baseline install did not create $cfg" } + Add-Content $env:GITHUB_ENV "PREV_CFG_HASH=$(Get-UserConfigHash)" + Write-Host "Baseline config hash: $(Get-UserConfigHash)" - name: Download new release assets env: @@ -660,6 +806,13 @@ jobs: throw "Old install at $env:OLD_INSTALL_DIR still present after relocating upgrade" } } + $hash = Get-UserConfigHash + if ($hash -ne $env:PREV_CFG_HASH) { + Get-Content (Get-UserConfigPath) | Select-Object -First 40 | Out-String | Write-Host + throw "upgrade replaced the existing user config (hash $hash, expected $env:PREV_CFG_HASH)" + } + Write-Host 'Existing user config preserved across upgrade.' + Assert-StartMenuShortcuts -Installed $true Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)" Write-Host 'Upgrade verified.' @@ -673,6 +826,9 @@ jobs: run: | . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + Assert-StartMenuShortcuts -Installed $false + # The config component is Permanent: user settings survive an uninstall. + if (-not (Test-Path (Get-UserConfigPath))) { throw 'user config was removed by uninstall' } - name: Upload msiexec logs if: failure() diff --git a/.github/workflows/update-site.yml b/.github/workflows/update-site.yml index b445867d..45fd4485 100644 --- a/.github/workflows/update-site.yml +++ b/.github/workflows/update-site.yml @@ -9,7 +9,6 @@ on: workflow_dispatch: env: - ffmpeg_version: "8.0" UV_SYSTEM_PYTHON: 1 @@ -60,6 +59,12 @@ jobs: git rm website/build/* -r -f --ignore-unmatch git config --global user.name github-actions git config --global user.email github-actions@github.com - git commit -a -m "[docs] @${{ github.triggering_actor }}: Generate Website" \ - -m "Commit: ${{ github.sha }}" - git push + # A re-pushed tag or a repeat dispatch can regenerate an identical site; `git commit` + # exits 1 with nothing to commit, so skip the publish instead of failing the run. + if git diff --quiet && git diff --cached --quiet; then + echo "Generated website is identical to gh-pages; nothing to publish." + else + git commit -a -m "[docs] @${{ github.triggering_actor }}: Generate Website" \ + -m "Commit: ${{ github.sha }}" + git push + fi diff --git a/MANIFEST.in b/MANIFEST.in index 01df64d0..c1e5b279 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,9 +5,7 @@ recursive-include dvr_scan/docs * include dvr-scan.cfg include dvr_scan/dvr-scan.ico include dvr_scan/dvr-scan.png -include dvr_scan/dvr-scan.svg include dvr_scan/dvr-scan-logo.png include LICENSE include pyproject.toml include README.md -include setup.cfg diff --git a/README.md b/README.md index cc811de0..feb46c72 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ------------------------------------------------ -### Latest Release: v1.8.2.1 (May 9, 2026) +### Latest Release: v2.0 (November 8, 2026) **Website**: [dvr-scan.com](https://www.dvr-scan.com) @@ -26,9 +26,9 @@ Windows builds (installer + portable) are also available on [the Downloads page] ## Quickstart (UI) -Start DVR-Scan (run `dvr-scan-app` or click the app shortcut), **Add** your input videos, and hit **Start**: +Start DVR-Scan (run `dvr-scan-app` or click the app shortcut). The Scan Wizard walks you through each step: add your input videos, pick a preset, choose where to save the output, and hit **Start Scan**: -main app window +scan wizard See the [User Guide](https://www.dvr-scan.com/guide/) for a more comprehensive overview. diff --git a/dist/build_headless.py b/dist/build_headless.py index 0255c82e..3df0d4c1 100644 --- a/dist/build_headless.py +++ b/dist/build_headless.py @@ -38,6 +38,16 @@ assert '"opencv-contrib-python"' in content content = content.replace('"opencv-contrib-python"', '"opencv-contrib-python-headless"', 1) +# scenedetect-headless depends on opencv-python-headless instead of opencv-python (same +# version constraints otherwise), so the headless package pulls in no GUI OpenCV build. +assert '"scenedetect[pyav]' in content +content = content.replace('"scenedetect[pyav]', '"scenedetect-headless[pyav]', 1) + +# Drop the direct opencv-python dependency (see the comment in pyproject.toml): the +# headless OpenCV build is pulled in through scenedetect-headless. +assert re.search(r'^\s*"opencv-python",\n', content, flags=re.MULTILINE) +content = re.sub(r'^\s*"opencv-python",\n', "", content, count=1, flags=re.MULTILINE) + # Remove screeninfo (GUI dependency) assert "screeninfo" in content content = re.sub(r'^\s*"screeninfo",?\n', "", content, flags=re.MULTILINE) diff --git a/dist/installer/DVR-Scan.aip b/dist/installer/DVR-Scan.aip index 91b1ca71..a41e8278 100644 --- a/dist/installer/DVR-Scan.aip +++ b/dist/installer/DVR-Scan.aip @@ -1,5 +1,5 @@ - + @@ -232,7 +232,7 @@ - + @@ -242,7 +242,7 @@ - + @@ -286,7 +286,7 @@ - + @@ -1280,9 +1280,6 @@ - - - @@ -2135,6 +2132,8 @@ + + diff --git a/dist/pre_release.py b/dist/pre_release.py index f0b4ec7c..a8f3271e 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -144,7 +144,9 @@ def build_docs(use_local_images=True): "assets/app-main-window.jpg", "assets/app-motion-settings.jpg", "assets/app-opencv-settings.jpg", - "assets/app-scan-window.jpg", + "assets/app-wizard-preset.jpg", + "assets/app-wizard-scan.jpg", + "assets/app-wizard-videos.jpg", "assets/bounding-box.gif", "assets/dvr-scan-logo-full.svg", "assets/dvr-scan-logo.svg", diff --git a/dist/requirements_windows.txt b/dist/requirements_windows.txt index f21aa79a..d0edeff6 100644 --- a/dist/requirements_windows.txt +++ b/dist/requirements_windows.txt @@ -11,7 +11,7 @@ pillow==13.0.0 platformdirs==4.14.3 pyinstaller>=6.0 pytest==9.2.0 -scenedetect==0.7.2 +scenedetect==0.7.3 screeninfo==0.8.1 tkinterdnd2==0.7.0 tqdm==4.70.1 diff --git a/docs/assets/app-main-window.jpg b/docs/assets/app-main-window.jpg index dda65953..f6895bd3 100644 Binary files a/docs/assets/app-main-window.jpg and b/docs/assets/app-main-window.jpg differ diff --git a/docs/assets/app-motion-settings.jpg b/docs/assets/app-motion-settings.jpg index 99cce68e..76b22bd4 100644 Binary files a/docs/assets/app-motion-settings.jpg and b/docs/assets/app-motion-settings.jpg differ diff --git a/docs/assets/app-scan-window.jpg b/docs/assets/app-scan-window.jpg deleted file mode 100644 index a2ef966f..00000000 Binary files a/docs/assets/app-scan-window.jpg and /dev/null differ diff --git a/docs/assets/app-wizard-preset.jpg b/docs/assets/app-wizard-preset.jpg new file mode 100644 index 00000000..6b34b17d Binary files /dev/null and b/docs/assets/app-wizard-preset.jpg differ diff --git a/docs/assets/app-wizard-scan.jpg b/docs/assets/app-wizard-scan.jpg new file mode 100644 index 00000000..00c22ae8 Binary files /dev/null and b/docs/assets/app-wizard-scan.jpg differ diff --git a/docs/assets/app-wizard-videos.jpg b/docs/assets/app-wizard-videos.jpg new file mode 100644 index 00000000..d1121e7a Binary files /dev/null and b/docs/assets/app-wizard-videos.jpg differ diff --git a/docs/changelog.md b/docs/changelog.md index 7d31a0e1..dff5d2f2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,42 +5,53 @@ #### Changelog -### 2.0 (In Development) +### 2.0 (2026-11-08) #### Release Notes -DVR-Scan v2.0 overhauls how timestamps are handled and simplifies the desktop application with a new Scan Wizard, settings presets, and drag-and-drop support. Motion events are now tracked using presentation timestamps (PTS), which means variable framerate (VFR) videos are handled correctly with default parameters. +DVR-Scan v2.0 is a major release that includes an improved new user experience, overhauls timestamp handling for VFR, adds settings presets, and brings drag-and-drop support. Under the hood, motion events are now tracked using presentation timestamps (PTS), which means variable framerate (VFR) videos are handled correctly with default parameters. This release has a *huge* amount of bugfixes and features, and also improves the online documentation. #### Changelog - * [general] DVR-Scan now requires PySceneDetect 0.7.2 or higher - * [general] Minimum supported Python version is now 3.11 (Python 3.10 is [EOL](https://devguide.python.org/versions/)) - * [general] Modernize project branding and logos, improve legibility at smaller sizes - * [feature] New Scan Wizard provides a simplified step-by-step workflow (add videos, pick a preset, choose output, scan) and is now the default mode when launching `dvr-scan-app` [#263](https://github.com/Breakthrough/DVR-Scan/issues/263) - * Set the new `startup-mode` config option to `classic` to start in the full settings view instead; you can also switch between modes at any time + * [breaking] The default output mode is now `encode` (MP4/H.264 via ffmpeg) instead of `opencv` (.avi), so motion events now include audio and are saved as .mp4 by default. If ffmpeg is not installed and no output mode is set, DVR-Scan falls back to `opencv` with a warning. Set `output-mode = opencv` in a config file (or use `-m opencv`) to restore the previous behavior. + * [breaking] Remove the `-roi`/`--region-of-interest` option and the `region-of-interest` config option (deprecated since v1.6). Use the region editor (`-r`/`--region-editor`), `-a`/`--add-region`, or `-R`/`--load-region` instead. + * [breaking] Remove `--use-pts` option; presentation timestamps are now always used for event timing (the `use-pts` config option is deprecated and has no effect) + * [feature] New Scan Wizard provides a simplified step-by-step workflow (add videos, pick a preset, choose output, scan) [#263](https://github.com/Breakthrough/DVR-Scan/issues/263) + * New installs start in the wizard; existing installs keep the full settings view (now called Advanced Mode). You can switch between modes at any time + * Set the default with the new `File -> Startup Mode` menu or the `startup-mode` config option (`wizard` or `advanced`), or force a mode for a single launch with `dvr-scan-app --startup-mode wizard|advanced` + * Installs without a user config file (e.g. `pip`) start in the wizard unless DVR-Scan has run before (only when launched without `-c`/`--config` or `--ignore-user-config`); the choice is saved to a new user config file. Downgrading to v1.x afterwards requires removing `startup-mode` from `dvr-scan.cfg`, as older versions reject unknown options + * Start/end time and detection regions set in Advanced Mode also apply to wizard scans and are listed on the summary step + * Errors when starting a scan (e.g. an unreadable video) are shown in a dialog instead of failing silently, and output modes that need ffmpeg are marked unavailable when it is not installed * [feature] Settings presets: a new preset dropdown in the app allows applying built-in presets or saving your own [#263](https://github.com/Breakthrough/DVR-Scan/issues/263) * User presets are config files stored in a `presets` folder next to the user config file (`Settings -> Open Presets Folder`) - * The `Default` preset is the user config file itself, replacing the `Save As User Default` and `Reset To User Default` menu items + * The `Default` preset is the user config file itself, replacing the `Save As User Default` and `Reset To User Default` menu items; saving it preserves config options the app has no setting for (e.g. `startup-mode`, `verbosity`) * [feature] Video files can now be dragged and dropped onto the app window to add them as inputs; dropping a `.cfg` file loads it as the active settings [#263](https://github.com/Breakthrough/DVR-Scan/issues/263) * [feature] Add select all with Ctrl+A (Command+A on Mac), multiple selection with Shift/Ctrl+click, and remove selection [#269](https://github.com/Breakthrough/DVR-Scan/issues/269) - * [feature] Variable framerate (VFR) videos are now handled correctly: event start/end times are always derived from presentation timestamps [#20](https://github.com/Breakthrough/DVR-Scan/issues/20) - * [breaking] Remove the `-roi`/`--region-of-interest` option and the `region-of-interest` config option (deprecated since v1.6). Use the region editor (`-r`/`--region-editor`), `-a`/`--add-region`, or `-R`/`--load-region` instead. - * [breaking] Remove `--use-pts` option; presentation timestamps are now always used for event timing (the `use-pts` config option is deprecated and has no effect) + * [feature] Add a `--max-events N` flag to allow stopping after detecting `N` motion events [#261](https://github.com/Breakthrough/DVR-Scan/issues/261) + * [feature] New `-Y`/`--max-event-length` option and `max-event-length` config option: events longer than the given length are split into consecutive events of at most that length (no post-event padding is added at a split; if motion continues, the next event starts where the previous one ended) [#271](https://github.com/Breakthrough/DVR-Scan/issues/271) + * [feature] New `--merge-window`/`merge-window` option controls how close two groups of motion must be for them to be merged into the same event; `-tb`/`-tp` are now pure padding added around each event and no longer affect merging. The default (`auto`) uses the same value as `time-post-event`, which matches the merging behavior of previous versions. Setting `time-post-event` larger than `merge-window` caps the post-event padding to the merge window with a warning. [#195](https://github.com/Breakthrough/DVR-Scan/issues/195) [#72](https://github.com/Breakthrough/DVR-Scan/issues/72) + * [feature] Motion mask output (`-mo`/`--mask-output`) now encodes high-quality MP4 (H.264) videos via ffmpeg by default when ffmpeg is available, respecting `encode-args` [#275](https://github.com/Breakthrough/DVR-Scan/issues/275) + * [feature] The Region Editor now supports seeking across the input video timeline via a frame slider and `,`/`.` frame-stepping shortcuts, and starts from the configured `start-time` in the GUI [#248](https://github.com/Breakthrough/DVR-Scan/issues/248) + * [feature] Add `timecode-list-format` config option to control the format of the event list printed at the end of a scan (and output in `-q`/`--quiet` mode): `parts` (`start-end,start-end,...`, syntax of `mkvmerge --split parts:`), `timecodes` (default, `start,end,start,end,...`, syntax of `mkvmerge --split timecodes:`), or `lines` (one `start-end` pair per line) [#78](https://github.com/Breakthrough/DVR-Scan/issues/78) * [general] Reported event timecodes are now exact rather than quantized to whole frames; formatted timecodes may differ by up to 1ms from previous versions. Frame numbers shown in reports and overlays are approximations on VFR inputs. * [general] When using `--frame-skip`, the post-event window now covers the exact amount of time specified rather than rounding down to a multiple of the skip interval, and back-to-back events share their boundary timecode instead of leaving a one-frame gap. * [feature] Frames are now decoded with PyAV by default (`input-mode = pyav`), which provides more accurate timing and stream metadata; set `input-mode = opencv` to restore the previous behavior * [feature] Multiple input videos are concatenated on a continuous PTS-based timeline with support for seeking across inputs; frame rates no longer need to match exactly * [general] Videos with a delayed start (nonzero stream start time) now report event timecodes relative to the start of the file with all input modes * [general] Reported event boundaries may shift by a frame or two compared to previous releases, as PyAV and OpenCV decode frames with slightly different color conversion - * [feature] New output mode `encode` produces MP4 (H.264) files by piping decoded frames to ffmpeg, combining the quality of `ffmpeg` mode with support for overlays (`-bb`/`-tc`/`-fm`), multiple inputs, and combined output (`-o`, video-only); audio from the source video(s) is included and synchronized, including events spanning multiple inputs. Encoder settings can be customized with the new `encode-args` config option. [#197](https://github.com/Breakthrough/DVR-Scan/issues/197) + * [feature] New `--report` option writes detected events to a CSV or JSON file (format set by extension), including in scan-only mode; the GUI's Save Report dialog can also save JSON reports. CSV reports now include exact start/end times in seconds (frame numbers are approximations on variable framerate inputs). [#221](https://github.com/Breakthrough/DVR-Scan/issues/221) + * [feature] New output mode `encode` produces MP4 (H.264) files by piping decoded frames to ffmpeg, combining the quality of `ffmpeg` mode with support for overlays (`-bb`/`-tc`/`-fm`), multiple inputs, and combined output (`-o`, video-only); audio from the source video(s) is included, including for events spanning multiple inputs (on VFR inputs, see the note on playback speed below). Encoder settings can be customized with the new `encode-args` config option. [#197](https://github.com/Breakthrough/DVR-Scan/issues/197) + * [bugfix] Variable framerate (VFR) videos are now handled correctly: event start/end times are always derived from presentation timestamps [#20](https://github.com/Breakthrough/DVR-Scan/issues/20) * [bugfix] Motion events extracted from multiple input videos are now named after the source video containing the start of each event, instead of always using the first input's filename [#258](https://github.com/Breakthrough/DVR-Scan/issues/258) - * [breaking] The default output mode is now `encode` (MP4/H.264 via ffmpeg) instead of `opencv` (.avi), so motion events now include audio and are saved as .mp4 by default. If ffmpeg is not installed and no output mode is set, DVR-Scan falls back to `opencv` with a warning. Set `output-mode = opencv` in a config file (or use `-m opencv`) to restore the previous behavior. - * [feature] New `--report` option writes detected events to a CSV or JSON file (format set by extension), including in scan-only mode; the GUI's Save Report dialog can also - save JSON reports. CSV reports now include exact start/end times in seconds (frame numbers are approximations on variable framerate inputs). [#221](https://github.com/Breakthrough/DVR-Scan/issues/221) * [bugfix] Thumbnails (`--thumbnails highscore`) are now named after the source video containing each event when scanning multiple inputs, and are numbered correctly in scan-only mode (previously each event's thumbnail overwrote the last one) * [bugfix] Using `--thumbnails highscore` together with `-o`/`--output` no longer overwrites the combined output video with the thumbnail image; thumbnails are named after the combined output file instead - * [general] On VFR inputs, clips produced by `encode` mode are written at the input's average framerate: all frames are preserved, but playback speed will deviate from real time in sections whose local framerate differs from the average (use `ffmpeg` or `copy` mode if exact playback timing is required) + * [bugfix] Fixed audio misalignment in `encode` mode for inputs with a nonzero container start time (e.g. DVR `.ts` recordings) + * [general] `encode` mode output uses the `yuv420p` pixel format (H.264 High profile) for compatibility with most players unless `-pix_fmt` is set in `encode-args`, and odd frame sizes (e.g. mask output with `-df 3`) are padded to even dimensions + * [general] The frames written for each event now exactly match the reported event span + * [general] On VFR inputs, clips produced by `encode` mode are written at the input's average framerate: all frames are preserved, but playback speed will deviate from real time in sections whose local framerate differs from the average, and audio is not re-timed to match, so it can drift out of sync with the video (use `ffmpeg` or `copy` mode if exact playback timing is required) + * [bugfix] `quiet-mode = yes` in a config file no longer breaks scans started from the app + * [bugfix] The main window now shrinks to fit the screen on smaller displays instead of opening larger than the desktop * [bugfix] Fix syntax error due to f-string usage under Python 3.10 [#262](https://github.com/Breakthrough/DVR-Scan/issues/262) * [bugfix] Ensure motion high score is reset when thumbnails are disabled [#267](https://github.com/Breakthrough/DVR-Scan/issues/267) * [bugfix] Fix "stale" frame references being used as thumbnails [#268](https://github.com/Breakthrough/DVR-Scan/issues/268) @@ -50,24 +61,29 @@ DVR-Scan v2.0 overhauls how timestamps are handled and simplifies the desktop ap * [bugfix] Fix the Dockerfile working directory (`/videos/`) not matching the documented `docker run` command, add ffmpeg to the Docker image so the default output mode works, and update the Docker instructions [#133](https://github.com/Breakthrough/DVR-Scan/issues/133) * [bugfix] Input filenames containing `[` or `]` (or other wildcard characters) are no longer treated as patterns if the file exists. Wildcards are now also expanded in absolute paths, matches are sorted by filename so multiple inputs are concatenated in a predictable order, and nonexistent inputs are always rejected before scanning starts. [#171](https://github.com/Breakthrough/DVR-Scan/issues/171) * [bugfix] Fix region editor in the GUI showing the first frame of a previously removed video after changing the input list. Existing regions are kept when the new first video has the same resolution; otherwise they are reset (with a prompt to save any unsaved changes). [#274](https://github.com/Breakthrough/DVR-Scan/issues/274) - * [feature] Add a `--max-events N` flag to allow stopping after detecting `N` motion events [#261](https://github.com/Breakthrough/DVR-Scan/issues/261) - * [feature] New `--merge-window`/`merge-window` option controls how close two groups of motion must be for them to be merged into the same event; `-tb`/`-tp` are now pure padding added around each event and no longer affect merging. The default (`auto`) uses the same value as `time-post-event`, which matches the merging behavior of previous versions. Setting `time-post-event` larger than `merge-window` caps the post-event padding to the merge window with a warning. [#195](https://github.com/Breakthrough/DVR-Scan/issues/195) [#72](https://github.com/Breakthrough/DVR-Scan/issues/72) - * [feature] Motion mask output (`-mo`/`--mask-output`) now encodes high-quality MP4 (H.264) videos via ffmpeg by default when ffmpeg is available, respecting `encode-args` [#275](https://github.com/Breakthrough/DVR-Scan/issues/275) - * [feature] The Region Editor now supports seeking across the input video timeline via a frame slider and `,`/`.` frame-stepping shortcuts, and starts from the configured `start-time` in the GUI [#248](https://github.com/Breakthrough/DVR-Scan/issues/248) * [bugfix] Explicitly specified `-k`/`--kernel-size` values are no longer scaled down when `-df`/`--downscale-factor` is set (`auto` kernel size continues to scale with the downscaled resolution) [#194](https://github.com/Breakthrough/DVR-Scan/issues/194) * [bugfix] Log an explicit error message if OpenCV `VideoWriter` fails to open an output video file [#214](https://github.com/Breakthrough/DVR-Scan/issues/214) * [general] Log active motion detection and event parameters (`threshold`, `min_event_len`, `time_pre_event`, `time_post_event`, `merge_window`, `downscale_factor`, `frame_skip`) at the start of a scan [#186](https://github.com/Breakthrough/DVR-Scan/issues/186) - * [feature] Add `timecode-list-format` config option to control the format of the event list printed at the end of a scan (and output in `-q`/`--quiet` mode): `parts` (`start-end,start-end,...`, syntax of `mkvmerge --split parts:`), `timecodes` (default, `start,end,start,end,...`, syntax of `mkvmerge --split timecodes:`), or `lines` (one `start-end` pair per line) [#78](https://github.com/Breakthrough/DVR-Scan/issues/78) * [general] Remove the OpenCV version cap (`<4.13`); DVR-Scan now installs with the latest OpenCV 4.x/5.x releases [#257](https://github.com/Breakthrough/DVR-Scan/issues/257) + * [bugfix] When the `CNT` subtractor is unavailable, the CLI now explains how to restore it, and the app hides the option (as well as the Fast Scan preset) instead of failing to load + * [general] The Python package depends on `opencv-python` again in addition to `opencv-contrib-python`, as it is required by some dependencies (this reverses the v1.8.2 change from [#251](https://github.com/Breakthrough/DVR-Scan/issues/251)); both packages must be the same version + * A fix for this will be coordinated as per https://scenedetect.com/issues/601 for a follow up release + * [general] The `dvr-scan-headless` package now depends on `scenedetect-headless` and `opencv-contrib-python-headless`, so it no longer installs the GUI builds of OpenCV + * [general] New dependency on `tkinterdnd2` for drag and drop support in the app + * [general] Minimum supported Python version is now 3.11 (Python 3.10 is [EOL](https://devguide.python.org/versions/)) * [general] Updates to Windows distributions: - Python 3.12 -> Python 3.13 - OpenCV 4.10.0.84 -> 5.0.0.93 - - PyAV 18.0.0 -> 19.0.2 + - PyAV 19.0.2 is now bundled [#255](https://github.com/Breakthrough/DVR-Scan/issues/255) - ffmpeg 8.0 -> 9.0.2 +#### Contributors + +Thanks to [@elvis-epx](https://github.com/elvis-epx) for contributing to this release. + ### 1.8.2.1 (2026-05-09) -Re-release of Python distribution that fixes dependency pinning. +Re-release of Python distribution that fixes dependency pinning. Thanks to [@frafra](https://github.com/frafra) for migrating the package to `pyproject.toml` [#205](https://github.com/Breakthrough/DVR-Scan/pull/205). ### 1.8.2 (2026-01-18) diff --git a/docs/docs.md b/docs/docs.md index 4d98ef56..b58aeea6 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -11,7 +11,7 @@ To extract all motion events from a video, you can start with: dvr-scan -i video.mp4 -This will produce output events in your working directory starting with the prefix `video.DSME_` (e.g. `video.DSME_0001.avi`). If you want to limit scanning to a particular part of the video frame, you can use the [region editor](#region-editor): +This will produce output events in your working directory starting with the prefix `video.DSME_` (e.g. `video.DSME_0001.mp4`). If you want to limit scanning to a particular part of the video frame, you can use the [region editor](#region-editor): dvr-scan -i video.mp4 -r @@ -20,14 +20,14 @@ DVR-Scan should provide good results for most use cases, but can be fine tuned f Most program options can be set [using a config file](#config-file). DVR-Scan also looks for a `dvr-scan.cfg` file in the following locations: * Windows: `C:/Users/%USERNAME%/AppData/Local/DVR-Scan/dvr-scan.cfg` - * Linux: `~/.config/DVR-Scan/dvr-scan.cfg` or `$XDG_CONFIG_HOME/dvr-scan.cfg` - * OSX: `~/Library/Preferences/DVR-Scan/dvr-scan.cfg` + * Linux: `~/.config/DVR-Scan/dvr-scan.cfg` (or `$XDG_CONFIG_HOME/DVR-Scan/dvr-scan.cfg` if `XDG_CONFIG_HOME` is set) + * macOS: `~/Library/Application Support/DVR-Scan/dvr-scan.cfg` -These settings will be used by default each time you run DVR-Scan, unless you override them. Selecting the Settings -> Save As User Default option in the UI will overwrite this file. +These settings will be used by default each time you run DVR-Scan, unless you override them. Run `dvr-scan --help` to see the exact path on your system (listed under `-c`/`--config`). In the app, saving settings to the `Default` [preset](#presets) overwrites this file. ### Multiple Videos -You can specify multiple input videos as long as they have the same resolution and framerate: +You can specify multiple input videos as long as they have the same resolution (framerates do not need to match exactly): dvr-scan -i video1.mp4 video2.mp4 video*.mp4 @@ -111,11 +111,14 @@ The following options control terminal output: ### Input *
-i video.mp4, --input video.mp4
- Path to input video. May specify multiple input videos so long as they have the same resolution and framerate. Wildcards/globs are supported (e.g. `-i folder/*.mp4`). Extracted motion events use the filename of the first video only as a prefix. + Path to input video. May specify multiple input videos so long as they have the same resolution. Wildcards/globs are supported (e.g. `-i folder/*.mp4`). Multiple inputs are scanned as one continuous video, and each extracted motion event is named after the input video containing the start of the event (e.g. `video2.DSME_0003.mp4`). *
-c settings.cfg, --config settings.cfg
Path to config file. If not set, tries to load one automatically from the user settings folder (run `dvr-scan --help` to see the exact path for your system). See the [config file documentation](#config-file) for what options can be specified. + *
--ignore-user-config
+ Do not load the user config file. A config file given with `-c`/`--config` is still loaded. + #### Regions @@ -192,19 +195,19 @@ dvr-scan -i video.mp4 --start-time 00:35:52 --duration 00:05:00 ### Motion -All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in seconds as a number followed by `s` (`123s` or `123.45s`), or as number of frames (e.g. `1234`). When modifying detection options, it can be useful to generate a motion mask (`-mo mask.avi`) to visually see how DVR-Scan processes the input. +All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in seconds as a number followed by `s` (`123s` or `123.45s`), or as number of frames (e.g. `1234`). When modifying detection options, it can be useful to generate a motion mask (`-mo mask.mp4`) to visually see how DVR-Scan processes the input. *
-l time, --min-event-length time
Amount of time/frames that must have a motion score above the threshold setting before triggering a new event. ``` ---min-event-length 2 +--min-event-length 0.1s ``` - *
-l time, --max-event-length time
Maximum amount of time/frames allowed into a single event. Footage beyond this limit goes to a new event. + *
-Y time, --max-event-length time
Maximum length of a single event, including pre/post-event padding. Longer events are split into consecutive events of at most this length: no post-event padding is added at a split, and if motion continues the next event starts where the previous one ended. Padding or pre-roll that would exceed the limit is trimmed. ``` ---max-event-length 1000 +--max-event-length 999999s ``` @@ -236,7 +239,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se ``` - *
-t value, --threshold value
Minimum amount of motion a frame must have to trigger an event. Lower values are more sensitive to motion, and require less movement to trigger an event. If too high, some movement may not be detected. Setting too low of a threshold can result in false detections. May require adjustment if other motion settings are changed. + *
-t value, --threshold value
Minimum amount of motion a frame must have to trigger an event. Lower values are more sensitive to motion, and require less movement to trigger an event. If too high, some movement may not be detected. Setting too low of a threshold can result in false detections. May require adjustment if other motion settings are changed. The motion score is the average of the motion mask (`0` for background, `255` for motion) over the whole frame, or over the detection regions if set, so small or distant objects in a large area produce low scores: use a lower threshold or a smaller region for them (see [the FAQ](faq.md#why-is-some-motion-not-detected-or-why-are-there-false-detections)). ``` --threshold 0.15 @@ -261,7 +264,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se - *
-k size, --kernel-size size
Noise reduction kernel size, in pixels. Kernel size can be increased to filter out smaller movements from a scene. Must be an *odd* integer at least `3` or greater. Can also be set to `0` to disable, or `-1` to select automatically based on input resolution. Auto sizes are `7` for 1080p or higher, `5` for 720p, and `3` for 480p or lower. + *
-k size, --kernel-size size
Noise reduction kernel size, in pixels. Kernel size can be increased to filter out smaller movements from a scene. Must be an *odd* integer at least `3` or greater. Can also be set to `0` to disable, or `-1` to select automatically based on input resolution. Auto sizes are `7` for 1080p or higher, `5` for 720p, and `3` for 480p or lower. Larger auto sizes can remove small or distant moving objects; set `3` (or `0`) to keep them. ``` --kernel-size 5 @@ -271,7 +274,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se ### Output - *
-so, --scan-only
Do not save/extract events, only perform motion detection and display results. + *
-so, --scan-only
Do not save/extract events, only perform motion detection and display results. Can also be set with the `scan-only` config option. *
-d path, --output-dir path
Write all output files to `path`. If not specified, files are written in the working directory. @@ -295,7 +298,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se !!! warning "Multiple input files are not supported when `-m`/`--output-mode` is set to `ffmpeg` or `copy` (they are supported with `opencv` and `encode`). You can use `ffmpeg` to [concatenate all input videos](https://trac.ffmpeg.org/wiki/Concatenate) *before* using DVR-Scan, or [run DVR-Scan in a for-loop](guide.md#multiple-videos)." - *
-o video.avi, --output video.avi
Save all motion events to a single file, instead of the default (one file per event). Only supported when `-m`/`--output-mode` is `opencv` (requires `.avi` extension) or `encode` (video-only output). + *
-o video.mp4, --output video.mp4
Save all motion events to a single file, instead of the default (one file per event). Only supported when `-m`/`--output-mode` is `opencv` (requires `.avi` extension) or `encode` (video-only output). *
--report file
Write a report of all detected motion events to `file`, including in scan-only mode. The format is set by the file extension, and must be either `.csv` or `.json`. Relative paths are joined with `-d`/`--output-dir` when set. @@ -304,7 +307,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se ``` - *
-mo mask.avi, --mask-output mask.avi
Save a video containing the calculated motion mask on each frame. Useful for tuning motion detection. Requires `.avi` extension. + *
-mo mask.mp4, --mask-output mask.mp4
Save a video containing the calculated motion mask on each frame. Useful for tuning motion detection. The mask is encoded as MP4 (H.264) via ffmpeg when available (respecting `encode-args`); otherwise it is written with OpenCV and requires an `.avi` extension. *
--thumbnails highscore
Save a thumbnail of the frame with the highest motion score for each event. Thumbnails are named after the source video containing the event (e.g. `video.DSME_0001.jpg`), or after the combined output file when `-o`/`--output` is used. @@ -312,7 +315,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se overlay example -!!! note "Overlays are only supported when output mode is set to OpenCV." +!!! note "Overlays are supported with the `encode` (default) and `opencv` output modes, but not with `ffmpeg` or `copy`." *
-bb, --bounding-box
Draw a bounding box around the areas where motion is detected.
An optional amount of time for temporal smoothing can also be specified (e.g. `-bb 0.1s`). The default smoothing amount is 0.1 seconds. If set to 0 (`-bb 0`), smoothing is disabled. @@ -328,7 +331,7 @@ All time values can be given as a timecode (`HH:MM:SS` or `HH:MM:SS.nnn`), in se ## :fontawesome-regular-file:Config File -Settings are specified one per line as `option = value`. Lines starting with `#` are ignored as comments. The [`dvr-scan.cfg` config template](https://raw.githubusercontent.com/Breakthrough/DVR-Scan/releases/1.6/dvr-scan.cfg) can be saved and used as a starting point (it includes the information in this section as comments). +Settings are specified one per line as `option = value`. Lines starting with `#` are ignored as comments. The [`dvr-scan.cfg` config template](https://raw.githubusercontent.com/Breakthrough/DVR-Scan/releases/2.0/dvr-scan.cfg) can be saved and used as a starting point (it includes the information in this section as comments). Configuration options are set as `option = value`, and lines starting with `#` are ignored as comments. For example: @@ -361,7 +364,7 @@ bounding-box-color = 0xFF0000
*
verbosity
- Verbosity of console output (`debug`, `info`, `warning`, `error`). If set to `debug`, overrides `quiet-mode` unless set via command line. + Verbosity of log output (`debug`, `info`, `warning`, `error`). Applies to the console and to log files written with `--logfile`. When `quiet-mode` is enabled nothing except the final event list is printed to the console, regardless of verbosity. ``` verbosity = info @@ -369,10 +372,10 @@ bounding-box-color = 0xFF0000 *
startup-mode
- Which mode the DVR-Scan app (`dvr-scan-app`) starts in (`wizard` or `classic`). The Scan Wizard provides a simplified step-by-step workflow, while classic mode shows all settings at once. You can switch between modes at any time from the application. Has no effect on the `dvr-scan` command line. + Which mode the DVR-Scan app (`dvr-scan-app`) starts in (`wizard` or `advanced`). The Scan Wizard provides a simplified step-by-step workflow, while Advanced Mode shows all settings at once. You can switch between modes at any time from the application, and change this option from File -> Startup Mode. New installs start in the wizard (the config file created by the installer sets `startup-mode = wizard`); existing installs keep Advanced Mode. If no user config file exists at all (e.g. after `pip install`) and the app is launched without `-c`/`--config` or `--ignore-user-config`, it starts in the wizard unless DVR-Scan has run before, and saves that choice to a new user config file. To force a mode for a single launch, run `dvr-scan-app --startup-mode wizard` or `dvr-scan-app --startup-mode advanced`. The value `classic` (used by pre-release versions) is accepted as an alias for `advanced`. Has no effect on the `dvr-scan` command line. ``` - startup-mode = wizard + startup-mode = advanced ``` @@ -380,6 +383,16 @@ bounding-box-color = 0xFF0000 The DVR-Scan app includes a preset dropdown for saving and restoring groups of settings. User presets are stored as regular config files in a `presets` folder next to the user config file (use `Settings -> Open Presets Folder` to view it); the filename is used as the preset name. The special `Default` preset is the user config file itself, and is loaded automatically on startup. Files which are not valid config files are ignored. +The app also includes the following built-in presets. Each one only sets the options listed below; all other options are reset to the program defaults (not the values saved in your `Default` preset). + +| Preset | Options | +|--------|---------| +| High Sensitivity | `threshold = 0.075`, `kernel-size = 3`, `time-before-event = 2.0s`, `time-post-event = 3.0s` | +| Low Sensitivity | `threshold = 0.5`, `min-event-length = 0.6s`, `variance-threshold = 32`, `time-post-event = 1.5s` | +| Fast Scan | `bg-subtractor = CNT`, `frame-skip = 1`, `downscale-factor = 2`, `threshold = 0.2` | + +High Sensitivity catches faint or small motion, Low Sensitivity only triggers on sustained, significant motion, and Fast Scan trades accuracy for speed. See [this FAQ entry](faq.md#why-is-some-motion-not-detected-or-why-are-there-false-detections) for more on tuning motion detection. + #### Logging *
save-log
@@ -394,7 +407,7 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s Maximum number of log files to keep. Set to 0 to keep all log files. ``` - max-log-files = 4 + max-log-files = 15 ``` @@ -411,7 +424,7 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s *
downscale-factor
- Integer factor to shrink video before processing. Values <= 1 disable downscaling. Leave unset for auto. + Integer factor to shrink video before processing. Values <= 1 disable downscaling (the default, `0`, does not downscale). ``` downscale-factor = 2 @@ -426,6 +439,14 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s ``` + *
load-region
+ Load region data from a file (see `-R`/`--load-region`). Each line must be a list of points in the format specified by `-a`/`--add-region`, and each line is treated as a separate polygon. + + ``` + load-region = regions.txt + ``` + + @@ -443,6 +464,22 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s ``` + *
open-output-dir
+ Open the output folder when a scan completes. Only applies to the app (`dvr-scan-app`). + + ``` + open-output-dir = yes + ``` + + + *
scan-only
+ Only perform motion detection without writing any files to disk (same as `-so`/`--scan-only`). If set, `output-mode` is ignored. + + ``` + scan-only = no + ``` + + *
output-mode
Method of generating output videos: (`scan_only`, `opencv`, `encode`, `ffmpeg`, `copy`). Not all features are supported in all modes. The default (`encode`) requires ffmpeg; if ffmpeg is unavailable and no mode is set, `opencv` is used as a fallback. @@ -454,7 +491,7 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s !!! warning "Multiple input files are not supported when `-m`/`--output-mode` is set to `ffmpeg` or `copy` (they are supported with `opencv` and `encode`). You can use `ffmpeg` to [concatenate all input videos](https://trac.ffmpeg.org/wiki/Concatenate) *before* using DVR-Scan, or [run DVR-Scan in a for-loop](guide.md#multiple-videos)." *
encode-args
- Encoder parameters used when generating output files when *output-mode* is *encode*. Stream mappings (`-map`) are generated automatically based on the audio streams present in the input video(s) and must not be set here. + Encoder parameters used when generating output files when *output-mode* is *encode*. Stream mappings (`-map`) are generated automatically based on the audio streams present in the input video(s) and must not be set here. If no `-pix_fmt` is given, `-pix_fmt yuv420p` is added automatically for player compatibility, and a pad filter is appended so the output dimensions are even. ``` encode-args = -c:v libx264 -preset veryfast -crf 22 -c:a aac @@ -473,7 +510,7 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s Encoder parameters used when generating output files when *output-mode* is *ffmpeg*. These arguments are added after the input and duration. ``` - ffmpeg-output-args = -map 0 -c:v libx264 -preset fast -crf 21 -c:a aac -sn + ffmpeg-output-args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac ``` @@ -485,6 +522,14 @@ The DVR-Scan app includes a preset dropdown for saving and restoring groups of s ``` + *
thumbnails
+ Save a thumbnail image for each event (same as `--thumbnails`). Must be `highscore` (saves the frame with the highest motion score). Thumbnails are not saved if unset. + + ``` + thumbnails = highscore + ``` + + *
timecode-list-format
Format of event list printed at the end of a scan (this is exactly what is output in `quiet-mode`). Must be one of:
    @@ -515,15 +560,15 @@ All time values can be given as a timecode: (`HH:MM:SS` or `HH:MM:SS.nnn`), in s Amount of time which must have motion in each frame to trigger an event. ``` - min-event-length = 2 + min-event-length = 0.1s ``` *
    max-event-length
    - Maximum mount of time of a single event. Footage beyond this limit goes to a new event. + Maximum length of a single event, including pre/post-event padding. Longer events are split into consecutive events of at most this length: no post-event padding is added at a split, and if motion continues the next event starts where the previous one ended. Padding or pre-roll that would exceed the limit is trimmed. ``` - max-event-length = 1000 + max-event-length = 999999s ``` @@ -553,7 +598,7 @@ All time values can be given as a timecode: (`HH:MM:SS` or `HH:MM:SS.nnn`), in s *
    threshold
    - Threshold representing amount of motion in a frame (or the ROI, if set) for a motion event to be triggered. + Threshold representing amount of motion in a frame (or in the detection regions, if set) for a motion event to be triggered. The motion score is the average of the motion mask (`0` for background, `255` for motion) over the whole frame, or over the detection regions if set, so small or distant objects in a large area produce low scores: use a lower threshold or a smaller region for them (see [the FAQ](faq.md#why-is-some-motion-not-detected-or-why-are-there-false-detections)). ``` threshold = 0.15 @@ -589,7 +634,7 @@ All time values can be given as a timecode: (`HH:MM:SS` or `HH:MM:SS.nnn`), in s *
    kernel-size
    - Size (in pixels) of the noise reduction kernel. Size must be an odd number starting from 3, 0 to disable, or -1 to auto-set based on video resolution. + Size (in pixels) of the noise reduction kernel. Size must be an odd number starting from 3, 0 to disable, or -1 to auto-set based on video resolution. Larger auto sizes (used for high resolution videos) can remove small or distant moving objects; set `3` (or `0`) to keep them. ``` kernel-size = -1 @@ -637,7 +682,7 @@ Color values can be specified as either `(R,G,B)` or in hex as `0xFFFFFF`. Time #### Bounding Box Overlays -*
    bounding-box
    +*
    bounding-box
    Enable bounding box: (`yes` or `no`). ``` @@ -697,18 +742,18 @@ Color values can be specified as either `(R,G,B)` or in hex as `0xFFFFFF`. Time *
    text-border
    - Margin from edge in pixels. + Padding between the text and the edge of its background box, in pixels. ``` - text-margin = 5 + text-border = 4 ``` *
    text-margin
    - Margin from edge in pixels. + Margin between the background box and the edge of the frame, in pixels. ``` - text-margin = 5 + text-margin = 4 ``` @@ -716,7 +761,7 @@ Color values can be specified as either `(R,G,B)` or in hex as `0xFFFFFF`. Time Scale factor for text size. ``` - text-font-scale = 2.0 + text-font-scale = 1.0 ``` diff --git a/docs/download.md b/docs/download.md index 9ea1bbf7..71ada7e8 100644 --- a/docs/download.md +++ b/docs/download.md @@ -11,7 +11,7 @@ hide: ## Python:fontawesome-brands-windows::fontawesome-brands-apple::fontawesome-brands-linux: -!!! python-download "**1.8.2.1**May 9, 2026" +!!! python-download "**2.0**November 8, 2026"

    pipx (recommended):

    @@ -29,9 +29,9 @@ Linux users may need to install the `python3-tk` package (e.g. `sudo apt install ## Windows:fontawesome-brands-windows: -!!! windows-download "**1.8.2**January 18, 2026" +!!! windows-download "**2.0**November 8, 2026" -
    [:fontawesome-solid-download:   Installer `.msi`](https://github.com/Breakthrough/DVR-Scan/releases/download/v1.8.2-release/dvr-scan-1.8.2-win64.msi){ .md-button #download-button }[:fontawesome-solid-file-zipper:   Portable `.zip`](https://github.com/Breakthrough/DVR-Scan/releases/download/v1.8.2-release/dvr-scan-1.8.2-win64.zip){ .md-button #changelog-button }
    +
    [:fontawesome-solid-download:   Installer `.msi`](https://github.com/Breakthrough/DVR-Scan/releases/download/v2.0-release/dvr-scan-2.0-win64.msi){ .md-button #download-button }[:fontawesome-solid-file-zipper:   Portable `.zip`](https://github.com/Breakthrough/DVR-Scan/releases/download/v2.0-release/dvr-scan-2.0-win64.zip){ .md-button #changelog-button }
    The installer is recommended for most users. Windows builds include all required dependencies to run DVR-Scan. Only 64-bit builds are available. @@ -40,7 +40,7 @@ The installer is recommended for most users. Windows builds include all require ## Servers -For headless systems that do not require the UI, you can install `dvr-scan-headless`. This will make sure that [the headless version of OpenCV](https://pypi.org/project/opencv-python-headless/) is installed, which avoids any dependencies on X11 libraries or any other GUI components. This allows DVR-Scan to run with less dependencies, and can result in smaller Docker images. +For headless systems that do not require the UI, you can install `dvr-scan-headless`. This will make sure that [the headless version of OpenCV](https://pypi.org/project/opencv-python-headless/) is installed, which avoids any dependencies on X11 libraries or any other GUI components. This allows DVR-Scan to run with fewer dependencies, and can result in smaller Docker images. ------------------------------- @@ -53,7 +53,7 @@ The source code for [DVR-Scan is available on Github](https://github.com/Breakth ## CUDA® Support -DVR-Scan works with CUDA graphics cards *if* you are using the Python distribution, *and* you have a CUDA-enabled verison of the `opencv-python` package. +DVR-Scan works with CUDA graphics cards *if* you are using the Python distribution, *and* you have a CUDA-enabled version of the `opencv-python` package. !!! warning "GPU support requires a development environment setup including the Nvidia CUDA® SDK." @@ -62,7 +62,7 @@ It is recommended to build and install OpenCV with the CUDA module enabled (ther - [Breakthrough/opencv-python-cuda](https://github.com/Breakthrough/opencv-python-cuda/releases/tag/4.12.0-dev2) - [cudawarped/opencv-python-cuda-wheels](https://github.com/cudawarped/opencv-python-cuda-wheels/releases/tag/4.11.0.20250124) -These can be downloaded and installed with . When available, you should see `cv2.cuda: Installed` under the features list when running `dvr-scan --version`. Make sure to set `-b MOG2_CUDA` when running DVR-Scan (e.g. `dvr-scan -i video.mp4 -b MOG2_CUDA`). In the UI, under this can be found under Settings -> Motion -> Subtractor. +These can be downloaded and installed with `pip install` followed by the path to the downloaded `.whl` file. When available, you should see `cv2.cuda: Installed` under the features list when running `dvr-scan --version`. Make sure to set `-b MOG2_CUDA` when running DVR-Scan (e.g. `dvr-scan -i video.mp4 -b MOG2_CUDA`). In the UI, this can be found under Settings -> Motion -> Subtractor. ------------------------------- diff --git a/docs/faq.md b/docs/faq.md index be6dd064..2e07024d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -63,16 +63,55 @@ done ### How can I improve scanning performance? -Adjusting [motion detection settings](docs.md#motion-settings) can have a large effect on performance: +Adjusting [motion detection settings](docs.md#motion) can have a large effect on performance: - Limiting detection to a specific region of the frame with the region editor will also improve performance - - Setting the output mode to either `ffmpeg` or `copy` can also improve performance compared to the default (`opencv`) + - Setting the output mode to either `ffmpeg` or `copy` can also improve performance compared to the default (`encode`), since they avoid re-encoding every frame - Downscaling high resolution videos can also improve performance greatly, at the expense of accuracy ---------------------------------------------------------- +### Why is some motion not detected (or why are there false detections)? + +For each frame, DVR-Scan calculates a *motion score* and compares it against the [`-t`/`--threshold`](docs.md#motion) setting (default `0.15`). The score is the average value of the motion mask (`0` for background, `255` for motion) over every pixel in the frame, or over the detection regions if any are set. For example, a moving object covering 1% of the frame scores about `2.5`, and the default threshold is reached when roughly 0.06% of the pixels are moving. + +Because the score is an average, the same object scores lower the larger the area it is averaged over. A small or distant subject (e.g. a person at the far end of a long hallway) can be picked up perfectly by the background subtractor and still score below the threshold. Limiting detection to a smaller region around where the motion happens raises its score. + +**If motion is missed**, try the following, in order: + + 1. Lower the threshold (e.g. `-t 0.05`, or lower for very small or distant subjects). + 2. Set the noise reduction kernel size to `-k 3`, or disable it with `-k 0`. The default (`auto`) uses a larger kernel for high resolution videos (`7` for 1080p or higher), which removes moving objects smaller than the kernel. + 3. Do not downscale or skip frames: keep `-df`/`--downscale-factor` and `-fs`/`--frame-skip` at their defaults (no downscaling, no frame skipping). + 4. Limit detection to a smaller region around the area of interest (see [Regions](docs.md#regions) and the [region editor](guide.md#region-editor)). + 5. In the app, start from the High Sensitivity [preset](docs.md#presets). + +**If there are too many false detections** (e.g. from foliage, shadows, rain, or lighting changes): + + - Raise the threshold. + - Raise `-l`/`--min-event-length` so short bursts of noise do not trigger an event. + - Raise the `variance-threshold` [config option](docs.md#config-file) (MOG2 only). + - Use a larger kernel size (e.g. `-k 5` or `-k 7`). + - Use regions to exclude areas with constant movement (trees, busy roads, screens). + - In the app, start from the Low Sensitivity [preset](docs.md#presets). + +**Use the motion mask to find the cause.** Generate a motion mask video without extracting any events: + +``` +dvr-scan -i video.mp4 -so -fm -mo mask.mp4 +``` + +The mask shows what the detector sees after downscaling and noise reduction: moving pixels are white, the background is black, and areas outside the detection regions are gray. If the subject is visible in the mask but no event is detected, the threshold is too high for the size of the frame or region. If the subject is missing from the mask, reduce the kernel size or downscale factor. The `-fm`/`--frame-metrics` flag draws the motion score of each frame in the top right corner (of the mask, and of extracted events), which helps when choosing a threshold. The mask is encoded using ffmpeg when it is available; otherwise it is written with OpenCV, in which case use an `.avi` filename (e.g. `-mo mask.avi`). + +The detection parameters in use (threshold, kernel size, downscale factor, frame skip, etc.) are logged at the start of every scan, which is useful to include when asking for help. + +Subjects close to the camera produce much higher scores than distant ones, so a single threshold may not suit both. All regions in a scan are combined into a single area for scoring, so if both matter, run separate scans, each with its own region and threshold (e.g. a small region around the distant area with a lower threshold). + + +---------------------------------------------------------- + + ### How can I join several videos together when processing? If you have a series of video clips from the same source, you can append subsequent video clips to the DVR-Scan input by including multiple files after `-i`. For example: @@ -89,7 +128,7 @@ dvr-scan -i video*.mp4 ``` -Each video **must** have the same resolution and framerate. Videos are processed in the same order as they appear in the command, and extracted events will use the first video's filename as a template. +Each video **must** have the same resolution (framerates do not need to match exactly). Videos are processed in the same order as they appear in the command (wildcard matches are sorted by filename), and each extracted event is named after the input video containing the start of the event. Multiple input videos are not supported when output mode `-m`/`--output-mode` is set to either `ffmpeg` or `copy`. As a workaround, you can [use ffmpeg to concatenate all input videos](https://trac.ffmpeg.org/wiki/Concatenate) before using DVR-Scan to process them. diff --git a/docs/guide.md b/docs/guide.md index 6ce5782f..ce1005d6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -9,18 +9,35 @@ After installation, you can run DVR-Scan by clicking on the application shortcut !!! note "The `dvr-scan` command is the command-line interface for DVR-Scan. To launch the GUI, run `dvr-scan-app`. For help with the command-line interface or config files, see the [Documentation](docs.md) page for details. " +### Scan Wizard and Advanced Mode + +DVR-Scan has two views. The Scan Wizard walks you through a scan step by step: add videos, pick a preset, choose an output folder, and scan. Advanced Mode (described below) shows all settings at once. You can switch between them at any time using File -> Scan Wizard in Advanced Mode, or the Advanced Mode button in the wizard. + +The wizard opens on the Add Videos step. Click the drop zone (or drag and drop files onto the window) to add one or more videos, then press Next: + +[Scan Wizard: Add Videos] + +Next, pick a Preset. Default uses your saved settings, High Sensitivity catches faint or small motion, Low Sensitivity reduces false detections, and Fast Scan trades accuracy for speed (see [Presets](docs.md#presets) for the settings each one changes). You can also adjust the sensitivity threshold directly, where lower values detect smaller amounts of motion (if motion you expect is not detected, see [this FAQ entry](faq.md#why-is-some-motion-not-detected-or-why-are-there-false-detections)): + +[Scan Wizard: Choose Preset] + +On the Choose Output step, select how events should be saved (or Scan Only to just produce a report), review your choices, and press Start Scan. The scan runs in place, and the detected events are listed when it finishes: + +[Scan Wizard: Scan Results] + +New installs start in the wizard, while existing installs keep Advanced Mode. To change which view opens at startup, use File -> Startup Mode in either view (this sets the [`startup-mode`](docs.md#config-file) config option). To force a view for a single launch, run `dvr-scan-app --startup-mode wizard` or `dvr-scan-app --startup-mode advanced`. + +### Advanced Mode + Start by clicking the Add button to add one or more videos: [Main App Window] You can select several videos at once with Shift-click, Ctrl-click, or Command-click on Mac. Select every video with `Ctrl + A` (`Command + A` on Mac). To remove the selected videos, use the `Delete` key or the Remove button. These actions are also available from the Edit menu. -To extract and save the motion events from the input, click the Start button. You will be prompted for a location to save the events to, after which scanning will begin: - - -[Scan Window] +To extract and save the motion events from the input, click the Start button. You will be prompted for a location to save the events to, after which scanning will begin in a separate progress window. Once complete, each event will be saved to the output folder you selected. -Once complete, each event will be saved to the output folder you selected. To tune motion sensitivity, go to Settings -> Motion and adjust threshold, where lower values are more sensitive to motion, and higher values are less sensitive. You can also adjust event settings and more: +To tune motion sensitivity, go to Settings -> Motion and adjust threshold, where lower values are more sensitive to motion, and higher values are less sensitive. You can also adjust event settings and more: [Motion Settings Window] diff --git a/docs/index.md b/docs/index.md index aeac2083..01912a2a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ hide: ------------------------------------------------------ -!!! success "Latest Version: 1.8.2 (January 18, 2026)" +!!! success "Latest Version: 2.0 (November 8, 2026)"
    [:fontawesome-solid-download:   Download](download.md){ .md-button #download-button }[:fontawesome-solid-book: User Guide](guide.md){ .md-button #changelog-button }[:fontawesome-solid-bars:   Documentation](docs.md){ .md-button #documentation-button }[:fontawesome-solid-gear:   Resources](resources.md){ .md-button #quickstart-button }
    @@ -26,9 +26,9 @@ DVR-Scan is a command-line application that **automatically detects motion event ### UI -Launch DVR-Scan from the shortcut or run the `dvr-scan-app` command. Press **Add** to add your input videos, and press **Start** to find and extract segments with motion: +Launch DVR-Scan from the shortcut or run the `dvr-scan-app` command. The Scan Wizard walks you through each step: add your input videos, pick a preset, choose where to save the output, and press **Start Scan** to find and extract segments with motion: -ui example +ui example To limit detection to certain areas, press **Set Regions** and use [the **Region Editor**](guide.md#region-editor). Motion sensitivity can be adjusted under **Settings -> Motion**. See the [User Guide](guide.md) for more information. diff --git a/docs/index_docs.md b/docs/index_docs.md index b6bdbc1e..a7b5c0be 100644 --- a/docs/index_docs.md +++ b/docs/index_docs.md @@ -14,9 +14,9 @@ hide: ------------------------------------------------------ -!!! success "Latest Version: 1.8.2 (January 18, 2026)" +!!! success "Latest Version: 2.0 (November 8, 2026)" -
    [:fontawesome-solid-book: User Guide](guide.md){ .md-button #changelog-button }[:fontawesome-solid-bars:   Documentation](docs.md){ .md-button #documentation-button }[:fontawesome-solid-gear:   Resources](changelog.md){ .md-button #download-button }
    +
    [:fontawesome-solid-book: User Guide](guide.md){ .md-button #changelog-button }[:fontawesome-solid-bars:   Documentation](docs.md){ .md-button #documentation-button }[:fontawesome-solid-gear:   Resources](resources.md){ .md-button #download-button }
    ------------------------------------------------------ @@ -26,9 +26,9 @@ DVR-Scan is a command-line application that **automatically detects motion event ### UI -Launch DVR-Scan from the shortcut or run the `dvr-scan-app` command. Press **Add** to add your input videos, and press **Start** to find and extract segments with motion: +Launch DVR-Scan from the shortcut or run the `dvr-scan-app` command. The Scan Wizard walks you through each step: add your input videos, pick a preset, choose where to save the output, and press **Start Scan** to find and extract segments with motion: -ui example +ui example To limit detection to certain areas, press **Set Regions** and use [the **Region Editor**](guide.md#region-editor). Motion sensitivity can be adjusted under **Settings -> Motion**. See the [User Guide](guide.md) for more information. diff --git a/dvr-scan.cfg b/dvr-scan.cfg index bc396d11..d0589442 100644 --- a/dvr-scan.cfg +++ b/dvr-scan.cfg @@ -9,9 +9,9 @@ # Windows: C:/Users/%USERNAME%/AppData/Local/DVR-Scan/dvr-scan.cfg # # Linux: ~/.config/DVR-Scan/dvr-scan.cfg -# $XDG_CONFIG_HOME/dvr-scan.cfg +# ($XDG_CONFIG_HOME/DVR-Scan/dvr-scan.cfg if XDG_CONFIG_HOME is set) # -# Mac: ~/Library/Preferences/DVR-Scan/dvr-scan.cfg +# macOS: ~/Library/Application Support/DVR-Scan/dvr-scan.cfg # # Run `dvr-scan --help` to see the exact path on your system which will be # used (it will be listed under the help text for the -c/--config option). @@ -21,10 +21,13 @@ # APPLICATION # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -# Which mode the DVR-Scan app (dvr-scan-app) starts in: WIZARD or CLASSIC. -# The Scan Wizard provides a simplified step-by-step workflow, while classic -# mode shows all settings at once. Has no effect on the command line. -#startup-mode = WIZARD +# Which mode the DVR-Scan app (dvr-scan-app) starts in: WIZARD or ADVANCED. +# The Scan Wizard provides a simplified step-by-step workflow, while Advanced +# Mode shows all settings at once. If this option is not set, the app starts +# in ADVANCED mode. Can also be changed from File -> Startup Mode in the app, +# or overridden for one launch with `dvr-scan-app --startup-mode`. Has no +# effect on the command line. +startup-mode = WIZARD # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * @@ -67,7 +70,8 @@ #output-mode = ENCODE # Encoder parameters used when generating output files in ENCODE mode. Stream -# mappings are generated automatically and must not be set here. +# mappings are generated automatically and must not be set here. If no -pix_fmt +# is given, -pix_fmt yuv420p is added and odd frame sizes are padded to even. #encode-args = -c:v libx264 -preset veryfast -crf 22 -c:a aac # Arguments to add before the input when calling ffmpeg in output-mode FFMPEG @@ -75,7 +79,7 @@ #ffmpeg-input-args = -v error # Encoder parameters used when generating output files in FFMPEG mode. -#ffmpeg-output-args = -map 0 -c:v libx264 -preset fast -crf 21 -c:a aac -sn +#ffmpeg-output-args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac # Four-letter identifier of the encoder/video codec to use in OPENCV mode. # Possible values are: XVID, MP4V, MP42, H264 @@ -108,7 +112,9 @@ # Amount of time which must have motion in each frame to trigger an event. #min-event-length = 0.1s -# Maximum amount of time of a single event. +# Maximum length of a single event, including pre/post-event padding. Longer +# events are split into consecutive events of at most this length (no padding +# is added at a split; if motion continues the next event starts there). #max-event-length = 999999s # Amount of time to include before an event. @@ -122,10 +128,11 @@ # which matches the behavior of previous versions. #merge-window = auto -# Threshold representing amount of motion in a frame (or the ROI, if set) for -# a motion event to be triggered. Lower values require less movement, and are -# more sensitive to motion. If too high, some movement may not be detected, -# while too low of a threshold can result in false detection events. +# Threshold representing amount of motion in a frame (or in the detection +# regions, if set) for a motion event to be triggered. Lower values require +# less movement, and are more sensitive to motion. If too high, some movement +# may not be detected, while too low of a threshold can result in false +# detection events. #threshold = 0.15 @@ -202,10 +209,10 @@ # Enable/disable frame metrics overlay in top right of video frame. #frame-metrics = yes -# Size of border around text, in pixels. Must be <= text-margin. +# Padding between the text and the edge of its background box, in pixels. #text-border = 4 -# Size of margin around border, in pixels. +# Margin between the background box and the edge of the frame, in pixels. #text-margin = 4 # Font scale factor. @@ -225,12 +232,13 @@ # LOGGING # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -# Suppress all console output. Output can still be saved using -# the -l/--logfile option. +# Suppress all console output except for the final event list (see +# timecode-list-format). Output can still be saved using the --logfile option. #quiet-mode = no -# Verbosity of console output (debug, info, warning, error). -# If set to debug, overrides quiet-mode unless set via command line. +# Verbosity of log output (debug, info, warning, error). Applies to the console +# and to --logfile. If quiet-mode is enabled, nothing except the final event +# list is printed to the console regardless of verbosity. #verbosity = info # Automatically save rolling logs. Useful for bug reports. diff --git a/dvr_scan/__init__.py b/dvr_scan/__init__.py index cb0ed2db..5d19967b 100644 --- a/dvr_scan/__init__.py +++ b/dvr_scan/__init__.py @@ -35,7 +35,7 @@ import sys # Used for module/distribution identification. -__version__ = "2.0-dev0" +__version__ = "2.0" # Handle loading OpenCV. This **MUST** be first before any other DVR-Scan or third-party # packages are imported which might attempt to import the `cv2` module. diff --git a/dvr_scan/__main__.py b/dvr_scan/__main__.py index 7b3e4024..c14b57e3 100644 --- a/dvr_scan/__main__.py +++ b/dvr_scan/__main__.py @@ -65,6 +65,12 @@ def main_impl(): logger.critical("Error (Not Implemented): %s", str(ex), exc_info=show_traceback) if debug_mode: raise + except (OSError, RuntimeError) as ex: + # I/O errors (e.g. an unwritable output path) and ffmpeg failures: the message + # is enough to act on, so only show the traceback in debug mode. + logger.critical("Error: %s", str(ex), exc_info=show_traceback) + if debug_mode: + raise except Exception as ex: logger.critical("Critical Error: %s", str(ex), exc_info=True) if debug_mode: diff --git a/dvr_scan/app/__main__.py b/dvr_scan/app/__main__.py index 93338bb9..8986cc22 100644 --- a/dvr_scan/app/__main__.py +++ b/dvr_scan/app/__main__.py @@ -21,6 +21,13 @@ import dvr_scan from dvr_scan import get_license_info from dvr_scan.app.application import Application +from dvr_scan.app.startup import ( + STARTUP_MODE_OPTION, + has_prior_logs, + normalize_startup_mode, + resolve_default_startup_mode, + save_startup_mode, +) from dvr_scan.config import CHOICE_MAP, USER_CONFIG_FILE_PATH, ConfigLoadFailure, ConfigRegistry from dvr_scan.shared import ( ScanSettings, @@ -40,6 +47,13 @@ LOGFILE_PATH = logfile_path(name_prefix="dvr-scan-app") +def _startup_mode_type(value: str) -> str: + """argparse type for `--startup-mode`: accepts the legacy alias "classic" (normalized + to "advanced") in addition to the values in CHOICE_MAP, any case.""" + check = string_type_check(CHOICE_MAP[STARTUP_MODE_OPTION], False, "mode") + return check(normalize_startup_mode(value)) + + def get_cli_parser(): parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -101,6 +115,16 @@ def get_cli_parser(): help=("Ignore loading the user config file if present."), ) + parser.add_argument( + "--startup-mode", + metavar="mode", + type=_startup_mode_type, + help=( + "Mode to start the application in. Must be one of: %s. Overrides the startup-mode " + "config option for this launch only." % (", ".join(CHOICE_MAP["startup-mode"]),) + ), + ) + parser.add_argument( "--debug", action="store_true", @@ -188,10 +212,53 @@ def main(): show_traceback = getattr(logging, settings.get("verbosity").upper()) == logging.DEBUG # TODO: Use Python __debug__ mode instead of hard-coding as config option. debug_mode = settings.get("debug") + + # Resolve the default startup mode (wizard or advanced). The first-run heuristic only + # applies when the user config is in play and no file was given explicitly, since it + # may write the result to the user config file. + allow_heuristic = not args.ignore_user_config and not hasattr(args, "config") + default_startup_mode, persist_startup_mode = resolve_default_startup_mode( + config, + user_config_exists=USER_CONFIG_FILE_PATH.exists(), + prior_logs=has_prior_logs(LOGFILE_PATH.parent, exclude=LOGFILE_PATH), + allow_heuristic=allow_heuristic, + ) + if persist_startup_mode: + try: + path = save_startup_mode(default_startup_mode) + logger.debug("first run: saved startup-mode = %s to %s", default_startup_mode, path) + except (OSError, UnicodeError) as ex: + logger.warning("Failed to save startup mode to user config file: %s", ex) + startup_mode = ( + normalize_startup_mode(args.startup_mode) + if hasattr(args, "startup_mode") + else default_startup_mode + ) + # The File > Startup Mode menu shows and edits the value in the user config file. With + # -c or --ignore-user-config that is not the config we launched from, so read it here + # (falling back to the program default if the user config is missing or invalid). + menu_startup_mode = default_startup_mode + if not allow_heuristic: + try: + user_config = ConfigRegistry() + user_config.load(log_level=logging.DEBUG) + menu_startup_mode = normalize_startup_mode(user_config.get(STARTUP_MODE_OPTION)) + except ConfigLoadFailure: + menu_startup_mode = normalize_startup_mode(ConfigRegistry().get(STARTUP_MODE_OPTION)) + logger.debug( + "startup mode: %s (default: %s, user config: %s)", + startup_mode, + default_startup_mode, + menu_startup_mode, + ) + with logging_redirect_tqdm(loggers=[logger]): try: app = Application( - settings=settings, initial_videos=args.input if hasattr(args, "input") else [] + settings=settings, + initial_videos=args.input if hasattr(args, "input") else [], + startup_mode=startup_mode, + default_startup_mode=menu_startup_mode, ) app.run() diff --git a/dvr_scan/app/application.py b/dvr_scan/app/application.py index 0dd167fd..41bb87ac 100644 --- a/dvr_scan/app/application.py +++ b/dvr_scan/app/application.py @@ -9,6 +9,7 @@ # LICENSE file, or visit one of the above pages for details. # +import argparse import copy import dataclasses import os @@ -17,6 +18,7 @@ import tkinter.messagebox import tkinter.simpledialog import tkinter.ttk as ttk +import traceback import typing as ty import webbrowser from datetime import datetime @@ -33,13 +35,20 @@ import dvr_scan.presets as presets from dvr_scan.app.about_window import AboutWindow -from dvr_scan.app.common import MenuBar, register_icon +from dvr_scan.app.common import ( + MenuBar, + add_startup_mode_menu, + fit_window_to_screen, + register_icon, +) from dvr_scan.app.region_editor import ACCELERATOR_KEY, RegionEditor from dvr_scan.app.scan_window import ScanWindow, launch_scan +from dvr_scan.app.startup import CLASSIC, WIZARD, normalize_startup_mode, save_startup_mode from dvr_scan.app.thumbnails import ( FILMSTRIP_FRAMES, FILMSTRIP_WIDTH, THUMBNAIL_STYLE, + THUMBNAIL_TEXT_GAP, ThumbnailLoader, configure_thumbnail_rows, filmstrip_placeholder, @@ -56,7 +65,7 @@ from dvr_scan.platform import is_ffmpeg_available, open_path from dvr_scan.scanner import OutputMode, Point from dvr_scan.shared import ScanSettings, logfile_path -from dvr_scan.subtractor import SubtractorCudaMOG2 +from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2 WINDOW_TITLE = "DVR-Scan" PADDING = 8 @@ -73,6 +82,8 @@ MAX_THRESHOLD = 255.0 MAX_DOWNSCALE_FACTOR = 128 NO_REGIONS_SPECIFIED_TEXT = "No Region(s) Specified" +# Value of an unset start/end time entry. +ZERO_TIMECODE = "00:00:00.000" EXPAND_HORIZONTAL = tk.EW # Fields the input list can be sorted by, used to build the File > Sort menu. @@ -141,6 +152,15 @@ class VideoInfo: date: str +def parse_resolution(text: str) -> ty.Optional[ty.Tuple[int, int]]: + """Parse a `VideoInfo.resolution` string ("W x H") into (w, h), or None if invalid.""" + try: + width, height = str(text).split("x") + return (int(width), int(height)) + except ValueError: + return None + + def probe_video(path: str) -> ty.Optional[VideoInfo]: """Open `path` and extract display metadata (duration, framerate, resolution, creation date), returning a `VideoInfo` or None if the video could not be opened. Errors are @@ -207,8 +227,9 @@ def __init__(self, root: tk.Widget, simple: bool = False): ) # Each row gets a filmstrip preview (frames sampled across the video) decoded off # the UI thread; a shared placeholder is shown until the real strip is ready. - self._thumbnails = ThumbnailLoader(self._videos) - self._placeholder = to_photo(filmstrip_placeholder()) + # The filename follows the filmstrip in the same cell, so pad the image. + self._thumbnails = ThumbnailLoader(self._videos, text_gap=THUMBNAIL_TEXT_GAP) + self._placeholder = to_photo(filmstrip_placeholder(), margin_right=THUMBNAIL_TEXT_GAP) scroll_horizontal = ttk.Scrollbar(frame, orient=tk.HORIZONTAL, command=self._videos.xview) scroll_vertical = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self._videos.yview) @@ -341,9 +362,9 @@ def update(self, settings: ScanSettings) -> ty.Optional[ScanSettings]: settings.set("input", videos) if self.start_end_time: (start, end) = self.start_end_time - if start != "00:00:00.000": + if start != ZERO_TIMECODE: settings.set("start-time", start) - if end != "00:00:00.000": + if end != ZERO_TIMECODE: settings.set("end-time", end) start_frame = FrameTimecode(start, 1000.0).frame_num end_frame = FrameTimecode(end, 1000.0).frame_num @@ -420,7 +441,7 @@ def clear(self): self._videos.delete(*self._videos.get_children()) # Drop pending thumbnail jobs so they don't target deleted rows, then start fresh. self._thumbnails.cancel() - self._thumbnails = ThumbnailLoader(self._videos) + self._thumbnails = ThumbnailLoader(self._videos, text_gap=THUMBNAIL_TEXT_GAP) self._refresh_remove_state() def _refresh_remove_state(self): @@ -504,11 +525,46 @@ def _first_video_resolution(self) -> ty.Optional[ty.Tuple[int, int]]: if not children: return None try: - width, height = str(self._videos.item(children[0])["values"][2]).split("x") - return (int(width), int(height)) - except (IndexError, ValueError): + return parse_resolution(self._videos.item(children[0])["values"][2]) + except IndexError: return None + def scan_overrides( + self, first_video: str, resolution: ty.Optional[ty.Tuple[int, int]] + ) -> ty.Dict[str, ty.Any]: + """Start/end time and regions set here, as settings to apply to a scan of another + input list (the Scan Wizard's, which has no controls for them) so they are not + silently ignored there. Regions are pixel coordinates, so they are left out if the + wizard's first input (`first_video`, of size `resolution` if known) differs from the + video they were drawn on and has another resolution. Unlike `update`, an end time + before the start time is dropped (with a warning) rather than failing the scan.""" + overrides: ty.Dict[str, ty.Any] = {} + if self.start_end_time: + (start, end) = self.start_end_time + if start != ZERO_TIMECODE: + overrides["start-time"] = start + if end != ZERO_TIMECODE: + if FrameTimecode(end, 1000.0).frame_num <= FrameTimecode(start, 1000.0).frame_num: + logger.warning("Ignoring end time %s (not after start time %s).", end, start) + else: + overrides["end-time"] = end + if self._set_region.get() and self._regions: + if ( + self._region_editor_video == self._normalize_path(first_video) + or resolution is None + or self._region_editor_size is None + or resolution == self._region_editor_size + ): + overrides["regions"] = self._regions + else: + logger.warning( + "Regions were drawn on a %dx%d video but the first input is %dx%d, " + "ignoring regions.", + *self._region_editor_size, + *resolution, + ) + return overrides + def _reset_regions(self): self._regions = [] self._current_region.set(NO_REGIONS_SPECIFIED_TEXT) @@ -542,7 +598,7 @@ def _on_edit_regions(self): video_path = videos[0] normalized_path = self._normalize_path(video_path) start_time = self.start_end_time[0] if self.start_end_time else None - if start_time == "00:00:00.000": + if start_time == ZERO_TIMECODE: start_time = None # Reuse the existing editor (and its regions) as long as the first input and start time # are unchanged. Otherwise rebuild the editor so it shows a matching frame (#248, #274). @@ -551,11 +607,16 @@ def _on_edit_regions(self): or self._region_editor_video != normalized_path or getattr(self, "_region_editor_start_time", None) != start_time ): + # Frame the editor opens on, so frame stepping continues from the start time + # rather than jumping back to the beginning of the video. + initial_frame_num = 0 try: video = open_video(video_path, backend="opencv") if start_time is not None: try: - video.seek(FrameTimecode(start_time, video.frame_rate)) + target = FrameTimecode(start_time, video.frame_rate) + video.seek(target) + initial_frame_num = target.frame_num except ValueError: pass frame = video.read() @@ -599,6 +660,7 @@ def _on_edit_regions(self): debug_mode=False, # TODO: Wire up debug mode. save_path=None, on_close=self._on_region_editor_close, + initial_frame_num=initial_frame_num, ) if persisted_state is not None: self._region_editor.set_persisted(*persisted_state) @@ -755,11 +817,14 @@ def __init__(self, root: tk.Widget): self._bg_subtractor = tk.StringVar() combo = ttk.Combobox(frame, textvariable=self._bg_subtractor, width=SETTING_INPUT_WIDTH) combo.state(["readonly"]) + # CNT needs the contrib OpenCV build; it can be missing from pip installs where the + # regular opencv-python package was installed over opencv-contrib-python. + subtractors = ["MOG2"] + (["CNT"] if SubtractorCNT.is_available() else []) if SubtractorCudaMOG2.is_available(): - combo["values"] = ("MOG2_CUDA", "MOG2", "CNT") + combo["values"] = tuple(["MOG2_CUDA"] + subtractors) self._bg_subtractor.set("MOG2_CUDA") else: - combo["values"] = ("MOG2", "CNT") + combo["values"] = tuple(subtractors) self._bg_subtractor.set("MOG2") combo.grid(row=0, column=1, sticky=EXPAND_HORIZONTAL) @@ -1748,20 +1813,47 @@ def load(self, settings: ScanSettings): self.open_on_completion = settings.get("open-output-dir") +def loaded_config_is_default_preset(args: ty.Optional[argparse.Namespace]) -> bool: + """True if the settings the app started with come from the user config file (or + program defaults when no user config exists), i.e. match the `Default` preset. False + when a config file was given with `-c` or the user config was ignored.""" + if args is None: + return True + return not hasattr(args, "config") and not getattr(args, "ignore_user_config", False) + + class Application: def run(self): logger.debug("starting main loop") - if str(self._settings.get("startup-mode")).lower() == "wizard": + if self._startup_mode == WIZARD: self._open_wizard(initial=True) else: self._root.deiconify() self._root.focus() self._root.mainloop() - def __init__(self, settings: ScanSettings, initial_videos: ty.List[str]): + def __init__( + self, + settings: ScanSettings, + initial_videos: ty.List[str], + startup_mode: str = CLASSIC, + default_startup_mode: str = CLASSIC, + ): + """Arguments: + settings: Settings to initialize the UI from. + initial_videos: Videos to add to the input list. + startup_mode: Mode to show when `run()` is called ("wizard" or "advanced"; the + legacy alias "classic" is accepted for "advanced"). + default_startup_mode: The value persisted in the user config file, shown (and + edited) by the File > Startup Mode menu. Differs from `startup_mode` when + `--startup-mode`, `-c` or `--ignore-user-config` was given. + """ self._root = TkinterDnD.Tk() self._root.withdraw() self._settings: ScanSettings = None + self._startup_mode = normalize_startup_mode(startup_mode) + self._saved_startup_mode = normalize_startup_mode(default_startup_mode) + self._startup_mode_var = tk.StringVar(self._root, value=self._saved_startup_mode) self._root.option_add("*tearOff", False) self._root.title(WINDOW_TITLE) @@ -1809,20 +1901,54 @@ def __init__(self, settings: ScanSettings, initial_videos: ty.List[str]): self._root.drop_target_register(DND_FILES) self._root.dnd_bind("<>", self._on_drop) - # Make sure we don't suppress exceptions in debug mode. - # TODO: This should probably use the logger in release mode rather than the default from Tk. + # Make sure we don't suppress exceptions in debug mode. In release mode, log them + # and tell the user: Tk's default handler prints to stderr, which the frozen app + # does not have, so a failing button would otherwise silently do nothing. Callbacks + # of every window (including the wizard's Toplevel) are reported via the root. if settings.get("debug"): def error_handler(*args): raise self._root.report_callback_exception = error_handler + else: + self._root.report_callback_exception = self._report_callback_exception # Initialize UI state from config. self._initialize(settings) self._preset_area.refresh(presets.list_presets()) + # The user config *is* the Default preset, so show it as active until a setting is + # changed. Settings from `-c` or program defaults (`--ignore-user-config`) are not. + if loaded_config_is_default_preset(settings.args): + self._mark_preset_applied(presets.DEFAULT_PRESET_NAME) for path in initial_videos: self._input_area.add_video(path) + # The window is still withdrawn; the geometry applies once it is shown. + if fit_window_to_screen(self._root): + logger.debug("shrunk main window to fit screen: %s", self._root.geometry()) + + def _report_callback_exception(self, exc_type, exc_value, exc_traceback): + """Release-mode handler for exceptions raised in Tk callbacks: log the traceback + and show an error dialog instead of printing to a (possibly absent) stderr.""" + formatted = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + logger.critical(f"Unhandled error in UI callback:\n{formatted}") + try: + tkinter.messagebox.showerror( + title="DVR-Scan Error", + message=f"An unexpected error occurred:\n\n{exc_value}\n\n" + "See the log for details (Help > Open Logs).", + parent=self._dialog_parent(), + ) + except Exception as ex: # noqa: BLE001 - never raise from the error handler. + logger.debug(f"Failed to show error dialog: {ex}") + + def _dialog_parent(self) -> tk.Misc: + """The window message boxes should be parented to: the Scan Wizard while it is + shown, otherwise the Advanced Mode (root) window.""" + wizard = getattr(self, "_wizard", None) + if wizard is not None and wizard.is_visible: + return wizard.window + return self._root def _create_menubar(self): self._menubar = MenuBar(self._root) @@ -1831,10 +1957,11 @@ def _create_menubar(self): file_menu = self._menubar.add_menu("File", underline=0) file_menu.add_command( - label="Wizard", - underline=0, + label="Scan Wizard", + underline=5, command=self._open_wizard, ) + add_startup_mode_menu(file_menu, self._startup_mode_var, self._on_startup_mode_changed) file_menu.add_command( label="Start Scan", underline=0, @@ -1919,6 +2046,9 @@ def _open_wizard(self, initial: bool = False): # Imported here to avoid a circular import (the wizard reuses InputArea). from dvr_scan.app.wizard import ScanWizard + if not initial: + # Settings may have been edited in Advanced Mode since the preset was applied. + self._refresh_active_preset() if self._wizard is None: self._wizard = ScanWizard( self._root, @@ -1926,6 +2056,11 @@ def _open_wizard(self, initial: bool = False): on_close=self._destroy, on_switch_to_classic=self._on_switch_to_classic, on_preset_applied=self._on_preset_applied, + startup_mode_var=self._startup_mode_var, + on_startup_mode_changed=self._on_startup_mode_changed, + active_preset=self._active_preset, + on_load_config=self._load_config_into_wizard, + get_scan_overrides=self._input_area.scan_overrides, ) if not initial: self._wizard.sync_from_settings( @@ -1935,8 +2070,41 @@ def _open_wizard(self, initial: bool = False): self._root.withdraw() self._wizard.show() + def _load_config_into_wizard(self, path: str) -> bool: + """Load a config file dropped onto the Scan Wizard: reinitialize the Advanced Mode + UI from it (the source of truth for settings), then sync the wizard from that.""" + config = self._load_config_file(path) + if config is None: + return False + if self._wizard is not None: + # Pass the config itself too: it may set options with no Advanced Mode control. + self._wizard.sync_from_settings( + self._get_config_settings(), active_preset=None, config=config + ) + return True + + def _on_startup_mode_changed(self): + """Persist the default startup mode selected from a File > Startup Mode menu. + Always written, even if it matches the value this instance last saved: another + running instance may have changed the config file since.""" + mode = self._startup_mode_var.get() + try: + path = save_startup_mode(mode) + except (OSError, UnicodeError) as ex: + logger.error(f"Failed to save startup mode: {ex}") + self._startup_mode_var.set(self._saved_startup_mode) + tkinter.messagebox.showerror( + title="Startup Mode", + message="Failed to save startup mode to the config file. " + "See log messages for details.", + parent=self._dialog_parent(), + ) + return + self._saved_startup_mode = mode + logger.debug(f"saved startup-mode = {mode} to {path}") + def _on_switch_to_classic(self, videos: ty.List[str]): - """Show the classic main window, syncing videos and settings from the wizard.""" + """Show the Advanced Mode window, syncing videos and settings from the wizard.""" if self._input_area.videos != videos: self._input_area.clear() for path in videos: @@ -2009,6 +2177,10 @@ def _load_config(self): def _load_config_from_path(self, path: str) -> bool: """Load a config file and reinitialize the UI from it. Returns False and shows an error message if the file could not be loaded.""" + return self._load_config_file(path) is not None + + def _load_config_file(self, path: str) -> ty.Optional[ConfigRegistry]: + """`_load_config_from_path`, returning the loaded config (None on failure).""" try: config = ConfigRegistry() config.load(path) @@ -2018,13 +2190,14 @@ def _load_config_from_path(self, path: str) -> bool: tkinter.messagebox.showerror( title="Config Load Failure", message="Invalid config file. See log messages for details.", + parent=self._dialog_parent(), ) - return False + return None for log_level, log_str in config.consume_init_log(): logger.log(log_level, log_str) self._reload_config(config) - return True + return config def _reset_config(self, program_default: bool = False): if not tkinter.messagebox.askyesno( @@ -2068,6 +2241,7 @@ def _initialize(self, settings: ScanSettings): title="Regions Not Loaded", message="Warning: region file from config was not loaded.\n\n" "You can load it from the Region Editor.", + parent=self._dialog_parent(), ) self._input_settings_window.set(settings) self._motion_settings_window.set(settings) @@ -2115,6 +2289,10 @@ def _on_preset_dropdown_opened(self): # TODO: This stats the presets directory on every dropdown open, which is fine # for a local folder but could be slow on a network drive. Cache on mtime if so. self._preset_area.refresh(presets.list_presets()) + self._refresh_active_preset() + + def _refresh_active_preset(self): + """Blank the active preset if any setting changed since it was applied.""" if self._active_preset is not None and self._preset_snapshot != self._serialize_ui_state(): self._active_preset = None self._preset_snapshot = None @@ -2173,7 +2351,7 @@ def _on_save_preset(self): return try: path = presets.save_user_preset(name, self._get_config_settings()) - except OSError as ex: + except (OSError, UnicodeError) as ex: logger.error(f"Failed to save preset: {ex}") tkinter.messagebox.showerror( title="Preset Save Failure", diff --git a/dvr_scan/app/common.py b/dvr_scan/app/common.py index e6e2033d..662f36e4 100644 --- a/dvr_scan/app/common.py +++ b/dvr_scan/app/common.py @@ -6,7 +6,8 @@ # # Copyright (C) 2024 Brandon Castellano . # DVR-Scan is licensed under the BSD 2-Clause License; see the included -# LICENSE file, or visi +# LICENSE file, or visit one of the above pages for details. +# import importlib.resources as resources import os @@ -19,6 +20,7 @@ import PIL.ImageTk import dvr_scan +from dvr_scan.app.startup import ADVANCED, WIZARD # Hover-darken factor for menubar headings: multiply each RGB channel of the bar background # to derive the `activebackground`. Below 1.0 darkens; ~0.85 reads clearly but stays subtle. @@ -44,6 +46,43 @@ def _darken(widget: tk.Widget, color: str, factor: float = _MENUBAR_HOVER_FACTOR return f"#{red:02x}{green:02x}{blue:02x}" +def fit_window_to_screen( + window: ty.Union[tk.Tk, tk.Toplevel], margin_x: int = 40, margin_y: int = 120 +) -> bool: + """Shrink `window`'s initial size so it fits on screen when the size its contents request + is larger (e.g. the classic window's thumbnail list on a small laptop display). Resizable + content with a grid weight absorbs the difference. The margins leave room for window + decorations and the taskbar, which Tk cannot query portably. Returns True if the window + geometry was changed.""" + window.update_idletasks() + req_width, req_height = window.winfo_reqwidth(), window.winfo_reqheight() + max_width = max(window.winfo_screenwidth() - margin_x, 1) + max_height = max(window.winfo_screenheight() - margin_y, 1) + if req_width <= max_width and req_height <= max_height: + return False + window.geometry(f"{min(req_width, max_width)}x{min(req_height, max_height)}") + return True + + +def add_startup_mode_menu( + parent: tk.Menu, variable: tk.StringVar, command: ty.Callable[[], None] +) -> tk.Menu: + """Add a `Startup Mode` cascade to `parent` with Scan Wizard / Advanced Mode radio + buttons bound to `variable` ("wizard" or "advanced"). `command` runs after each + selection. Shared by the Advanced Mode window and the Scan Wizard so both menus stay + in sync.""" + menu = tk.Menu(parent, tearoff=0) + menu.add_radiobutton( + label="Scan Wizard", underline=5, variable=variable, value=WIZARD, command=command + ) + menu.add_radiobutton( + label="Advanced Mode", underline=0, variable=variable, value=ADVANCED, command=command + ) + # Underline "M": "S" collides with Start Scan / Sort in the File menus. + parent.add_cascade(label="Startup Mode", underline=8, menu=menu) + return menu + + class MenuBar: """A custom menubar built from `tk.Menubutton` headings so the top-level menus get a real hover highlight. The native Windows menubar (`toplevel["menu"] = ...`) exposes no diff --git a/dvr_scan/app/region_editor.py b/dvr_scan/app/region_editor.py index 5989d5bd..f071dcf1 100644 --- a/dvr_scan/app/region_editor.py +++ b/dvr_scan/app/region_editor.py @@ -182,7 +182,10 @@ def __init__( video_path: str, save_path: ty.Optional[str], on_close: ty.Optional[ty.Callable[[], None]] = None, + initial_frame_num: int = 0, ): + """`initial_frame_num` is the (0-based) frame number of `frame` in the video, so + frame stepping and the seek slider start from there (e.g. the scan start time).""" self._settings = EditorSettings(video_path=video_path, save_path=save_path) self._source_frame: np.ndarray = frame.copy() # Frame before downscaling self._source_size: Size = Size(w=frame.shape[1], h=frame.shape[0]) @@ -224,7 +227,7 @@ def __init__( self._updating_seek_widget: bool = False self._video = None self._total_frames: int = 0 - self._current_frame_num: int = 0 + self._current_frame_num: int = max(0, int(initial_frame_num)) self._pan_enabled: bool = False self._panning: bool = False self._controls_window: tk.Toplevel = None diff --git a/dvr_scan/app/scan_window.py b/dvr_scan/app/scan_window.py index 6b1d2867..bde8060f 100644 --- a/dvr_scan/app/scan_window.py +++ b/dvr_scan/app/scan_window.py @@ -20,7 +20,7 @@ from logging import getLogger from pathlib import Path -from scenedetect import FrameTimecode +from scenedetect import FrameTimecode, VideoOpenFailure from tqdm import tqdm from dvr_scan.app.thumbnails import ( @@ -67,8 +67,9 @@ def launch_scan( padding: int, ) -> ty.Optional["ScanWindow"]: """Create a `ScanWindow` for `settings`. Shows an error message and returns None - if the configured input mode is unavailable on this system. The caller must call - `show()` on the returned window to start the scan.""" + if the scan cannot be set up (input mode unavailable on this system, inputs that can't + be opened or combined, output that can't be created). The caller must call `show()` + on the returned window to start the scan.""" try: return ScanWindow(root, settings, on_closed, padding) except BackendUnavailable: @@ -76,6 +77,15 @@ def launch_scan( title="Input Mode Unavailable", message=f"The specified input mode ({settings.get('input-mode')}) " "is not available on this system.", + parent=root, + ) + return None + except (VideoOpenFailure, ValueError, OSError) as ex: + logger.error(f"Failed to start scan: {ex}", exc_info=True) + messagebox.showerror( + title="Scan Error", + message=f"Failed to start scan:\n\n{ex}\n\nSee the log for details (Help > Open Logs).", + parent=root, ) return None @@ -95,13 +105,22 @@ def __init__( on_finished: ty.Callable[[], None], padding: int, ): + # The UI stats are read from the scanner's tqdm progress bar, which quiet mode + # replaces with a stub, so always enable it (it only affects console output). + settings.set("quiet-mode", False) + # Set up the scanner first: it raises if the inputs or output can't be opened, and + # no widgets should be left behind in `parent` when it does. + self._scanner = init_scanner(settings) self.frame = ttk.Frame(parent) self._on_finished = on_finished self._finished_notified = False + # Pending `after` id of the UI update loop, cancelled if the frame is destroyed + # mid-scan (e.g. the window is closed) so it can't fire on a dead widget. + self._after_id: ty.Optional[str] = None + self.frame.bind("", self._on_frame_destroyed, add="+") # Source paths, exposed so a results table can seek them for event thumbnails. self.input_paths: ty.List[str] = [str(path) for path in settings.get("input")] - self._scanner = init_scanner(settings) self._scanner.set_callbacks( scan_started=self._on_scan_started, processed_frame=self._on_processed_frame, @@ -164,7 +183,7 @@ def start(self): """Begin scanning in a worker thread and start updating the UI.""" logger.debug("starting scan thread") self._scan_thread.start() - self.frame.after(10, self._update_continuous) + self._after_id = self.frame.after(10, self._update_continuous) def stop(self): """Signal the scan to stop and wait for the worker thread to exit.""" @@ -213,8 +232,17 @@ def _update(self) -> bool: return True def _update_continuous(self): + self._after_id = None if self._update(): - self.frame.after(UI_UPDATE_RATE_MS, self._update_continuous) + self._after_id = self.frame.after(UI_UPDATE_RATE_MS, self._update_continuous) + + def _on_frame_destroyed(self, _event=None): + if self._after_id is not None: + try: + self.frame.after_cancel(self._after_id) + except tk.TclError: + pass + self._after_id = None def _on_scan_started(self, num_frames: int): # We've now seeked to the start of the motion detection area and are processing frames. @@ -278,7 +306,7 @@ def _finalize_stats(self): class ScanWindow: - """Modal window that runs a scan from the classic (advanced) UI, showing progress + """Modal window that runs a scan from the Advanced Mode UI, showing progress and then a Stop/Close control. On a successful scan a report can be saved to disk.""" def __init__( @@ -291,7 +319,12 @@ def __init__( self._on_destroyed = on_destroyed self._padding = padding self._results_table: ty.Optional[ttk.Frame] = None - self._view = ScanProgressView(self._root, settings, self._on_finished, padding) + try: + self._view = ScanProgressView(self._root, settings, self._on_finished, padding) + except BaseException: + # Don't leave the (hidden) window behind if the scan can't be set up. + self._root.destroy() + raise self._root.bind("<>", self.stop) self._root.protocol("WM_DELETE_WINDOW", self.prompt_stop) @@ -332,6 +365,8 @@ def _on_finished(self): logger.debug("scan complete") self._stop_button["state"] = tk.DISABLED self._close_button["state"] = tk.NORMAL + # Nothing left to stop: closing the window now just closes it. + self._root.protocol("WM_DELETE_WINDOW", self._destroy) # A report is only meaningful for a completed scan that produced an event list. if self._view.result is not None and not self._view.was_stopped: self._save_button["state"] = tk.NORMAL @@ -391,18 +426,25 @@ def prompt_stop_scan(view: ScanProgressView, parent: tk.Misc) -> bool: def _event_frame_providers( input_paths: ty.List[str], event_list: ty.List[MotionEvent] -) -> ty.List[FrameProvider]: +) -> ty.Tuple[ty.List[FrameProvider], ty.Callable[[], None]]: """Build one frame provider per event, each sampling FILMSTRIP_FRAMES frames spread across the event for a filmstrip preview. All providers share a single input stream opened lazily on the worker thread, sampling by presentation time so this works for - single- and multi-video inputs alike.""" - video: ty.Optional[VideoStreamConcat] = None + single- and multi-video inputs alike. + + Also returns a `release` function which drops the shared stream so its files are + closed (the backends close them on deallocation); call it from the worker thread once + the providers are done (see `ThumbnailLoader`'s `on_exit`).""" + shared: ty.Dict[str, ty.Optional[VideoStreamConcat]] = {"video": None} + + def release(): + shared["video"] = None def make(event: MotionEvent) -> FrameProvider: def provide(): - nonlocal video + video = shared["video"] if video is None: - video = open_input([Path(path) for path in input_paths]) + video = shared["video"] = open_input([Path(path) for path in input_paths]) start = event.start.seconds span = max(0.0, event.end.seconds - start) frames = [] @@ -417,7 +459,7 @@ def provide(): return provide - return [make(event) for event in event_list] + return [make(event) for event in event_list], release def build_event_table( @@ -425,7 +467,7 @@ def build_event_table( ) -> ttk.Frame: """A scrollable table of motion events, one row each, with a thumbnail (decoded off the UI thread from the source video) in the tree column. Shared by the wizard report - panel and the classic `ScanWindow`.""" + panel and the Advanced Mode `ScanWindow`.""" configure_thumbnail_rows() container = ttk.Frame(parent) container.rowconfigure(0, weight=1) @@ -450,13 +492,16 @@ def build_event_table( table.column(col, width=width, minwidth=width, anchor=tk.CENTER, stretch=True) placeholder = to_photo(filmstrip_placeholder()) - loader = ThumbnailLoader(table) + providers, release_video = _event_frame_providers(input_paths, event_list) + # The source video is closed once the last thumbnail is done, or when the table is + # destroyed first, so it is not held open (or the loader thread leaked) after a scan. + loader = ThumbnailLoader(table, on_exit=release_video) + container.bind("", lambda _event: loader.cancel(), add="+") # Keep the placeholder and loader (which owns the decoded PhotoImages) alive for as # long as the table is displayed; Tk only stores image names, not Python references. container.thumbnail_placeholder = placeholder container.thumbnail_loader = loader - providers = _event_frame_providers(input_paths, event_list) for index, (event, provider) in enumerate(zip(event_list, providers, strict=True)): duration = event.end - event.start iid = table.insert( @@ -471,6 +516,9 @@ def build_event_table( ), ) loader.submit(iid, provider) + # Drop our references so only the loader's queue holds the providers. + providers = provider = None + loader.finish() scroll = ttk.Scrollbar(container, orient=tk.VERTICAL, command=table.yview) table.configure(yscrollcommand=scroll.set) diff --git a/dvr_scan/app/startup.py b/dvr_scan/app/startup.py new file mode 100644 index 00000000..23525dae --- /dev/null +++ b/dvr_scan/app/startup.py @@ -0,0 +1,110 @@ +# +# DVR-Scan: Video Motion Event Detection & Extraction Tool +# -------------------------------------------------------------- +# [ Site: https://www.dvr-scan.com/ ] +# [ Repo: https://github.com/Breakthrough/DVR-Scan ] +# +# Copyright (C) 2026 Brandon Castellano . +# DVR-Scan is licensed under the BSD 2-Clause License; see the included +# LICENSE file, or visit one of the above pages for details. +# + +"""Startup mode selection for the DVR-Scan application (Scan Wizard vs. Advanced Mode). + +The ``startup-mode`` config option holds the user's default. The code default is +``advanced`` so existing installs keep the full settings view, while the config template +shipped with the installer sets ``wizard`` so fresh installs start in the Scan Wizard. +Installs without any user config file (e.g. pip or portable) fall back to a one-time +heuristic: if DVR-Scan has written log files before, the user is treated as an existing +user (Advanced Mode), otherwise as a new one (wizard). The result is persisted to the user +config so later runs agree (the first run itself writes a log file). + +The legacy value ``classic`` (the name of Advanced Mode during 2.0 development) is still +accepted wherever a startup mode is read, and is normalized to ``advanced``. + +The ``--startup-mode`` command line option overrides the default for a single launch. +""" + +import logging +import typing as ty +from pathlib import Path + +from dvr_scan.config import ( + CHOICE_MAP, + CONFIG_MAP, + ConfigRegistry, + normalize_choice, + set_user_config_option, +) + +logger = logging.getLogger("dvr_scan") + +STARTUP_MODE_OPTION = "startup-mode" +WIZARD = "wizard" +ADVANCED = "advanced" +# Name used in code for Advanced Mode (the full settings window); the value is "advanced". +CLASSIC = ADVANCED +# Matches current (`dvr-scan-app..log`, `dvr-scan..log`) and legacy +# (`dvr-scan-app.log`, `dvr-scan.log`) log file names. +LOG_GLOB = "dvr-scan*.log" + + +def normalize_startup_mode(mode: str) -> str: + """Lowercase `mode` and map the legacy alias "classic" to "advanced". Does not + validate; unknown values are returned lowercased.""" + return normalize_choice(STARTUP_MODE_OPTION, str(mode)).strip().lower() + + +def has_prior_logs(log_dir: Path, exclude: ty.Optional[Path] = None) -> bool: + """True if `log_dir` holds any DVR-Scan log file other than `exclude` (the log file + of the current process, which may already exist).""" + if not log_dir.is_dir(): + return False + excluded = exclude.resolve() if exclude is not None else None + for path in log_dir.glob(LOG_GLOB): + if excluded is not None and path.resolve() == excluded: + continue + return True + return False + + +def resolve_default_startup_mode( + config: ConfigRegistry, + user_config_exists: bool, + prior_logs: bool, + allow_heuristic: bool, +) -> ty.Tuple[str, bool]: + """Determine the default startup mode. + + Arguments: + config: Loaded configuration (user config, or the one passed with `-c`). + user_config_exists: Whether a user config file exists on disk. + prior_logs: Whether DVR-Scan log files from earlier runs exist. + allow_heuristic: False when the user config is being ignored or a config file was + given explicitly, in which case the first-run heuristic must not run (and + nothing should be written to the user config). + + Returns: + (mode, needs_persist): `mode` is "wizard" or "advanced". `needs_persist` is True + when the mode was chosen by the first-run heuristic and should be written to the + user config so later runs agree. + """ + if not config.is_default(STARTUP_MODE_OPTION): + return normalize_startup_mode(config.get(STARTUP_MODE_OPTION)), False + if allow_heuristic and not user_config_exists: + return (CLASSIC if prior_logs else WIZARD), True + return normalize_startup_mode(CONFIG_MAP[STARTUP_MODE_OPTION]), False + + +def save_startup_mode(mode: str, path: ty.Optional[Path] = None) -> Path: + """Persist `mode` as the default startup mode in the user config file (or `path`). + The legacy alias "classic" is written as "advanced". + + Raises: + ValueError: `mode` is not a valid startup mode. + OSError: The config file could not be written. + """ + mode = normalize_startup_mode(mode) + if mode not in CHOICE_MAP[STARTUP_MODE_OPTION]: + raise ValueError(f"invalid startup mode: {mode!r}") + return set_user_config_option(STARTUP_MODE_OPTION, mode, path) diff --git a/dvr_scan/app/thumbnails.py b/dvr_scan/app/thumbnails.py index 9b17999f..6be159c4 100644 --- a/dvr_scan/app/thumbnails.py +++ b/dvr_scan/app/thumbnails.py @@ -50,6 +50,8 @@ # Gap (px) and its color between adjacent frames in the strip. FILMSTRIP_GAP = 2 FILMSTRIP_GAP_COLOR = (20, 20, 20) +# Space (px) between a filmstrip and the text in the same cell (classic input list). +THUMBNAIL_TEXT_GAP = 8 # Nominal per-frame width (16:9), used to size the placeholder and column before any # real frame (whose true aspect ratio may differ) has been decoded. _NOMINAL_FRAME_WIDTH = round(THUMBNAIL_HEIGHT * 16 / 9) @@ -97,9 +99,21 @@ def filmstrip_placeholder(height: int = THUMBNAIL_HEIGHT) -> PIL.Image.Image: return PIL.Image.new("RGB", (FILMSTRIP_WIDTH, height), PLACEHOLDER_COLOR) -def to_photo(image: PIL.Image.Image) -> PIL.ImageTk.PhotoImage: - """Wrap a `PIL.Image` as a Tk image. Must be called on the UI thread.""" - return PIL.ImageTk.PhotoImage(image) +def pad_right(image: PIL.Image.Image, margin: int) -> PIL.Image.Image: + """Return `image` with `margin` transparent pixels appended on the right. Used to put + space between a thumbnail and the text that follows it in the same treeview cell, + which ttk offers no option for. Returns `image` unchanged when `margin` is 0.""" + if margin <= 0: + return image + padded = PIL.Image.new("RGBA", (image.width + margin, image.height), (0, 0, 0, 0)) + padded.paste(image, (0, 0)) + return padded + + +def to_photo(image: PIL.Image.Image, margin_right: int = 0) -> PIL.ImageTk.PhotoImage: + """Wrap a `PIL.Image` as a Tk image, optionally with a transparent right margin (see + `pad_right`). Must be called on the UI thread.""" + return PIL.ImageTk.PhotoImage(pad_right(image, margin_right)) def configure_thumbnail_rows(style_name: str = THUMBNAIL_STYLE) -> str: @@ -128,7 +142,11 @@ class ThumbnailLoader: they are displayed. By default it drives `ttk.Treeview` rows (setting each row's `image`), but any widget - that lays out rows by `iid` can be targeted by passing a custom `apply_image`.""" + that lays out rows by `iid` can be targeted by passing a custom `apply_image`. + + The UI poll loop is scheduled on the (long-lived) Tk root rather than on `widget`, so a + pending poll never targets a destroyed widget (which raises Tk's "invalid command name" + error dialog); `cancel` also cancels it.""" def __init__( self, @@ -137,24 +155,36 @@ def __init__( height: int = THUMBNAIL_HEIGHT, poll_interval_ms: int = POLL_INTERVAL_MS, apply_image: ty.Optional[ApplyImage] = None, + text_gap: int = 0, + on_exit: ty.Optional[ty.Callable[[], None]] = None, ): + """`text_gap` adds a transparent right margin to each thumbnail, for cells where + text follows the image (see `pad_right`). `on_exit` runs once the worker stops (after + `finish` or `cancel`), on the worker thread, e.g. to close a video the providers + share; it runs on the calling thread if no job was ever submitted.""" self._widget = widget self._apply_image = apply_image if apply_image is not None else self._apply_to_tree self._height = height self._poll_interval_ms = poll_interval_ms + self._text_gap = text_gap + self._on_exit = on_exit self._jobs: "queue.Queue[ty.Optional[ty.Tuple[str, FrameProvider]]]" = queue.Queue() self._results: "queue.Queue[ty.Tuple[str, ty.Optional[PIL.Image.Image]]]" = queue.Queue() self._photos: ty.Dict[str, PIL.ImageTk.PhotoImage] = {} self._thread: ty.Optional[threading.Thread] = None self._cancelled = False + self._finished = False self._poller_active = False self._pending = 0 + # Widget the pending poll was scheduled on, and its `after` id. + self._scheduler: ty.Optional[tk.Misc] = None + self._after_id: ty.Optional[str] = None def submit(self, iid: str, provider: FrameProvider): """Queue a thumbnail to be decoded for row `iid`. Safe to call repeatedly as - rows are added; starts the worker and UI poller on first use. A cancelled loader - cannot be reused; construct a fresh instance instead.""" - if self._cancelled: + rows are added; starts the worker and UI poller on first use. A cancelled or + finished loader cannot be reused; construct a fresh instance instead.""" + if self._cancelled or self._finished: return self._pending += 1 self._jobs.put((iid, provider)) @@ -163,7 +193,15 @@ def submit(self, iid: str, provider: FrameProvider): self._thread.start() if not self._poller_active: self._poller_active = True - self._widget.after(self._poll_interval_ms, self._drain) + self._schedule_drain() + + def finish(self): + """Signal that no more jobs will be submitted: the worker thread exits (running + `on_exit`) once the queued jobs are done, instead of waiting for more.""" + if self._cancelled or self._finished: + return + self._finished = True + self._stop_worker() def cancel(self): """Stop loading and drop references. Call when the rows are cleared or the @@ -174,22 +212,61 @@ def cancel(self): return self._cancelled = True self._photos.clear() - self._jobs.put(None) + self._cancel_drain() + if not self._finished: + self._stop_worker() + + def _stop_worker(self): + """Wake the worker with the exit sentinel, or run `on_exit` now if it never ran.""" + if self._thread is not None: + self._jobs.put(None) + else: + self._run_on_exit() + + def _run_on_exit(self): + if self._on_exit is None: + return + on_exit, self._on_exit = self._on_exit, None + try: + on_exit() + except Exception as ex: # noqa: BLE001 - cleanup must not raise. + logger.debug(f"thumbnail loader cleanup failed: {ex}") def _work(self): - while True: - job = self._jobs.get() - if job is None or self._cancelled: - return - iid, provider = job - image: ty.Optional[PIL.Image.Image] = None + try: + while True: + job = self._jobs.get() + if job is None or self._cancelled: + return + iid, provider = job + image: ty.Optional[PIL.Image.Image] = None + frames = None + try: + frames = provider() + if frames: + image = filmstrip_image(frames, self._height) + except Exception as ex: # noqa: BLE001 - a bad frame must not kill the worker. + logger.debug(f"failed to generate thumbnail for {iid}: {ex}") + # Drop references before blocking on the next job, so an idle worker does not + # keep the last provider (and anything it holds open, e.g. a video) alive. + job = provider = frames = None + self._results.put((iid, image)) + finally: + self._run_on_exit() + + def _schedule_drain(self): + if self._scheduler is None: + self._scheduler = self._widget.nametowidget(".") + self._after_id = self._scheduler.after(self._poll_interval_ms, self._drain) + + def _cancel_drain(self): + if self._after_id is not None and self._scheduler is not None: try: - frames = provider() - if frames: - image = filmstrip_image(frames, self._height) - except Exception as ex: # noqa: BLE001 - a bad frame must not kill the worker. - logger.debug(f"failed to generate thumbnail for {iid}: {ex}") - self._results.put((iid, image)) + self._scheduler.after_cancel(self._after_id) + except tk.TclError: + pass # The root is already gone. + self._after_id = None + self._poller_active = False def _apply_to_tree(self, iid: str, photo: PIL.ImageTk.PhotoImage) -> bool: """Default `apply_image`: set the image on a `ttk.Treeview` row, skipping rows that @@ -201,6 +278,7 @@ def _apply_to_tree(self, iid: str, photo: PIL.ImageTk.PhotoImage) -> bool: return True def _drain(self): + self._after_id = None if self._cancelled or not self._widget.winfo_exists(): self._poller_active = False return @@ -209,12 +287,12 @@ def _drain(self): iid, image = self._results.get_nowait() self._pending -= 1 if image is not None: - photo = to_photo(image) + photo = to_photo(image, margin_right=self._text_gap) if self._apply_image(iid, photo): self._photos[iid] = photo except queue.Empty: pass if self._pending > 0: - self._widget.after(self._poll_interval_ms, self._drain) + self._schedule_drain() else: self._poller_active = False diff --git a/dvr_scan/app/video_list.py b/dvr_scan/app/video_list.py index c7dc31c6..cdac2957 100644 --- a/dvr_scan/app/video_list.py +++ b/dvr_scan/app/video_list.py @@ -47,6 +47,7 @@ PADDING, SORT_FIELDS, VideoInfo, + parse_resolution, probe_video, sample_video_frames, ) @@ -130,8 +131,13 @@ class VideoList: """Scrollable list of input videos with stacked metadata rows and a vertical icon toolbar. Built into `root`, mirroring the `InputArea` API used by the Scan Wizard.""" - def __init__(self, root: tk.Widget): + def __init__(self, root: tk.Widget, on_add: ty.Optional[ty.Callable[[], None]] = None): + """`on_add` handles the Add ("+") button. The Scan Wizard routes it through its own + browse handler, which parents the file dialog to the wizard and locks navigation + while it is open. Without it, the dialog is opened here (parented to this list's + window).""" self._root = root + self._on_add = on_add root.rowconfigure(0, weight=1) root.columnconfigure(0, weight=1) @@ -172,7 +178,9 @@ def __init__(self, root: tk.Widget): # same direction the selected row moves. toolbar = ttk.Frame(root) toolbar.grid(row=0, column=2, sticky=tk.N, padx=(PADDING, 0)) - self._add_button = self._toolbar_button(toolbar, 0, ADD_GLYPH, "Add videos", self.add_video) + self._add_button = self._toolbar_button( + toolbar, 0, ADD_GLYPH, "Add videos", self._on_add_clicked + ) self._remove_button = self._toolbar_button( toolbar, 1, REMOVE_GLYPH, "Remove selected (Del)", self.remove_selected ) @@ -217,6 +225,13 @@ def _on_mousewheel(self, event): def videos(self) -> ty.List[str]: return [self._rows[iid].info.path for iid in self._order] + @property + def first_video_resolution(self) -> ty.Optional[ty.Tuple[int, int]]: + """Resolution (w, h) of the first video in the list, or None if unknown/empty.""" + if not self._order: + return None + return parse_resolution(self._rows[self._order[0]].info.resolution) + @property def selected_videos(self) -> ty.List[str]: """Paths of the selected videos, in display order.""" @@ -255,6 +270,12 @@ def remove_selected(self): self._paint_selection() self._refresh_button_state() + def _on_add_clicked(self): + if self._on_add is not None: + self._on_add() + else: + self.add_video(parent=self._root.winfo_toplevel()) + def add_video(self, path: str = "", parent: ty.Optional[tk.Widget] = None): """Add a video to the list by path, or open a file dialog if no path is given. Called from the Add button, drag-and-drop, and the Scan Wizard.""" diff --git a/dvr_scan/app/wizard.py b/dvr_scan/app/wizard.py index a23cead0..54df6e87 100644 --- a/dvr_scan/app/wizard.py +++ b/dvr_scan/app/wizard.py @@ -28,6 +28,7 @@ import PIL import PIL.Image import PIL.ImageTk +from scenedetect import VideoOpenFailure from tkinterdnd2 import DND_FILES import dvr_scan @@ -38,7 +39,7 @@ SORT_FIELDS, finalize_output_names, ) -from dvr_scan.app.common import MenuBar, register_icon +from dvr_scan.app.common import MenuBar, add_startup_mode_menu, register_icon from dvr_scan.app.region_editor import ACCELERATOR_KEY from dvr_scan.app.scan_window import ( ScanProgressView, @@ -52,6 +53,7 @@ from dvr_scan.platform import is_ffmpeg_available, open_path from dvr_scan.scanner import OutputMode from dvr_scan.shared import ScanSettings, logfile_path +from dvr_scan.subtractor import SubtractorCNT from dvr_scan.video_input import BackendUnavailable WIZARD_TITLE = "DVR-Scan" @@ -69,6 +71,10 @@ DROP_ZONE_REFRESH_MS = 250 MAX_THRESHOLD = 255.0 MAX_SUMMARY_VIDEOS = 5 +# Output modes which run ffmpeg, and the label suffix shown for them when it is missing. +FFMPEG_OUTPUT_MODES = frozenset({OutputMode.ENCODE, OutputMode.FFMPEG, OutputMode.COPY}) +FFMPEG_MISSING_SUFFIX = " - requires ffmpeg (not found)" +RECOMMENDED_SUFFIX = " - Recommended" # Classes of focused widgets that handle Ctrl+A / Delete / BackSpace themselves; the video list # shortcuts are suppressed while one of these has keyboard focus. TEXT_ENTRY_CLASSES = frozenset({"Entry", "TEntry", "Spinbox", "TSpinbox", "TCombobox", "Text"}) @@ -115,7 +121,9 @@ def __init__( on_state_changed: ty.Optional[ty.Callable[[], None]] = None, on_browse: ty.Optional[ty.Callable[[], None]] = None, can_accept_drop: ty.Optional[ty.Callable[[], bool]] = None, + on_load_config: ty.Optional[ty.Callable[[str], bool]] = None, ): + """`on_load_config` loads a dropped `.cfg` file, returning True on success.""" self.frame = ttk.Frame(root) self.frame.rowconfigure(0, weight=1) self.frame.columnconfigure(0, weight=1) @@ -124,11 +132,14 @@ def __init__( self._on_state_changed = on_state_changed self._on_browse = on_browse self._can_accept_drop = can_accept_drop + self._on_load_config = on_load_config self._cta_logo = None self._drag_active = False self._input_frame = ttk.Frame(self.frame) - self.input_area = VideoList(self._input_frame) + # The list's "+" button goes through the same browse path as the drop zone and + # File > Open, so the dialog is parented to the wizard and navigation is locked. + self.input_area = VideoList(self._input_frame, on_add=on_browse) self._input_frame.grid(row=0, column=0, sticky=tk.NSEW) background = ttk.Style().lookup("TFrame", "background") or "white" @@ -219,11 +230,20 @@ def _on_drop(self, event): if self._can_accept_drop is not None and not self._can_accept_drop(): return # Dropped paths are a Tcl list (paths with spaces are wrapped in braces). + config_loaded = False for path_str in self._drop_zone.tk.splitlist(event.data): path = Path(path_str) if not path.is_file(): logger.info(f"Ignoring dropped item (not a file): {path}") continue + if path.suffix.lower() == ".cfg": + # Like the Advanced Mode window: a dropped config file is loaded as the + # active settings, and only the first one wins. + if self._on_load_config is None or config_loaded: + logger.warning(f"Ignoring dropped config file: {path}") + continue + config_loaded = self._on_load_config(str(path)) + continue self.input_area.add_video(str(path), parent=self._window) self._refresh() @@ -251,6 +271,23 @@ def validate(self) -> ty.Optional[str]: return None +def _available_presets() -> ty.List[presets.Preset]: + """Built-in and user presets, minus any whose background subtractor is not available + (e.g. Fast Scan uses CNT, which pip installs can lack when the regular OpenCV package + was installed over the contrib build).""" + available = [] + for preset in presets.list_presets(): + subtractor = (preset.overrides or {}).get("bg-subtractor", "").upper() + if subtractor == "CNT" and not SubtractorCNT.is_available(): + logger.warning( + "Hiding preset %s: the CNT subtractor is not available in this install.", + preset.name, + ) + continue + available.append(preset) + return available + + class _PresetStep: """Step 2: choose a preset and adjust sensitivity.""" @@ -276,11 +313,18 @@ def __init__( root: tk.Widget, on_preset_applied: ty.Callable[[presets.Preset, ConfigRegistry], None], base_config: ty.Optional[ConfigRegistry] = None, + active_preset: ty.Optional[str] = presets.DEFAULT_PRESET_NAME, ): + """`active_preset` names the preset `base_config` corresponds to (None when it + came from a config file given on the command line, or when the user config was + ignored); it is shown in the dropdown the first time the step is displayed.""" self.frame = ttk.Frame(root) self._on_preset_applied = on_preset_applied self._presets: ty.Dict[str, presets.Preset] = {} - self._active_preset: ty.Optional[str] = presets.DEFAULT_PRESET_NAME + self._active_preset: ty.Optional[str] = active_preset + # Set once the dropdown has been populated from `active_preset` or synced from + # classic mode, so a later `on_show` does not override the user's selection. + self._combo_initialized = False if base_config is not None: self.config = copy.deepcopy(base_config) else: @@ -332,7 +376,7 @@ def threshold(self) -> float: def _update_description(self): name = self._combo.get() if not name: - self._preset_desc["text"] = "Custom settings configured in Advanced Mode." + self._preset_desc["text"] = "Custom settings (from a config file or Advanced Mode)." else: self._preset_desc["text"] = self._PRESET_DESCRIPTIONS.get(name, "Custom user preset.") @@ -355,6 +399,7 @@ def _on_selected(self): tkinter.messagebox.showerror( title="Preset Load Failure", message=f"Failed to load preset: {preset.name}. See log messages for details.", + parent=self.frame.winfo_toplevel(), ) self._combo.set(self._active_preset if self._active_preset else "") return @@ -364,9 +409,19 @@ def _on_selected(self): self._update_description() self._on_preset_applied(preset, config) - def sync_from_settings(self, settings: ScanSettings, active_preset: ty.Optional[str] = None): - self._presets = {preset.name: preset for preset in presets.list_presets()} + def sync_from_settings( + self, + settings: ScanSettings, + active_preset: ty.Optional[str] = None, + config: ty.Optional[ConfigRegistry] = None, + ): + """Sync from Advanced Mode `settings`. `config`, if given, replaces the base config + first (e.g. a config file that was just loaded).""" + if config is not None: + self.config = copy.deepcopy(config) + self._presets = {preset.name: preset for preset in _available_presets()} self._combo["values"] = list(self._presets) + self._combo_initialized = True if active_preset and active_preset in self._presets: self._combo.set(active_preset) self._active_preset = active_preset @@ -381,11 +436,15 @@ def sync_from_settings(self, settings: ScanSettings, active_preset: ty.Optional[ self._update_description() def on_show(self): - self._presets = {preset.name: preset for preset in presets.list_presets()} + self._presets = {preset.name: preset for preset in _available_presets()} self._combo["values"] = list(self._presets) - if not self._combo.get() and self._preset_desc["text"] == "": - self._combo.set(presets.DEFAULT_PRESET_NAME) - self._active_preset = presets.DEFAULT_PRESET_NAME + if not self._combo_initialized: + self._combo_initialized = True + if self._active_preset in self._presets: + self._combo.set(self._active_preset) + else: + self._combo.set("") + self._active_preset = None self._update_description() def validate(self) -> ty.Optional[str]: @@ -414,14 +473,18 @@ def __init__( self.frame = ttk.Frame(root) self.frame.columnconfigure(1, weight=1) self._videos_step = videos_step + # Checked once: it spawns a process. A combobox can't disable single entries, so + # modes which need ffmpeg are labelled as unavailable and rejected by `validate`. + self._ffmpeg_available = is_ffmpeg_available() + self._modes = self.mode_choices(self._ffmpeg_available) tk.Label(self.frame, text="Mode").grid( row=0, column=0, sticky=tk.W, padx=PADDING, pady=PADDING ) self._mode_combo = ttk.Combobox(self.frame, state="readonly") - self._mode_combo["values"] = [label for label, _ in self._MODES] + self._mode_combo["values"] = [label for label, _ in self._modes] # Default to MP4 output, unless ffmpeg is unavailable on this system. - self._mode_combo.current(0 if is_ffmpeg_available() else 1) + self._mode_combo.current(0 if self._ffmpeg_available else 1) self._mode_combo.bind("<>", lambda _: self._on_mode_changed()) self._mode_combo.grid(row=0, column=1, columnspan=2, sticky=tk.EW, padx=PADDING) @@ -443,6 +506,24 @@ def __init__( else: self._on_mode_changed() + @classmethod + def mode_choices( + cls, ffmpeg_available: bool + ) -> ty.List[ty.Tuple[str, ty.Optional[OutputMode]]]: + """(label, mode) choices for the mode dropdown. Without ffmpeg, the modes that need + it are labelled as unavailable (and lose the "Recommended" tag).""" + if ffmpeg_available: + return list(cls._MODES) + return [ + ( + (label.removesuffix(RECOMMENDED_SUFFIX) + FFMPEG_MISSING_SUFFIX) + if mode in FFMPEG_OUTPUT_MODES + else label, + mode, + ) + for label, mode in cls._MODES + ] + def _on_mode_changed(self): self._mode_combo.selection_clear() if self.scan_only: @@ -456,7 +537,11 @@ def _on_mode_changed(self): def _on_select(self): initial_dir = self._directory if self._directory else None - dialog_options = {"title": "Set Output Directory", "mustexist": True} + dialog_options = { + "title": "Set Output Directory", + "mustexist": True, + "parent": self.frame.winfo_toplevel(), + } if initial_dir: dialog_options["initialdir"] = initial_dir directory = tkinter.filedialog.askdirectory(**dialog_options) @@ -483,18 +568,16 @@ def sync_from_settings(self, settings: ScanSettings): if isinstance(raw_mode, OutputMode) else OutputMode[str(raw_mode).upper().replace("-", "_")] ) - if ( - output_mode in (OutputMode.ENCODE, OutputMode.FFMPEG, OutputMode.COPY) - and settings.config.is_default("output-mode") - and not is_ffmpeg_available() - ): + if output_mode in FFMPEG_OUTPUT_MODES and not self._ffmpeg_available: + # Even if set explicitly: the mode can't run, so start from one that can. + logger.info(f"ffmpeg not found, using OpenCV output instead of {output_mode.name}.") output_mode = OutputMode.OPENCV target_mode = ( None if (settings.get("scan-only") or output_mode == OutputMode.SCAN_ONLY) else output_mode ) - for index, (_label, mode) in enumerate(self._MODES): + for index, (_label, mode) in enumerate(self._modes): if mode == target_mode: self._mode_combo.current(index) break @@ -513,12 +596,18 @@ def scan_only(self) -> bool: @property def output_mode(self) -> ty.Optional[OutputMode]: - return dict(self._MODES)[self._mode_combo.get()] + return dict(self._modes)[self._mode_combo.get()] @property def directory(self) -> str: return self._directory + @property + def user_selected_directory(self) -> bool: + """True if `directory` was chosen by the user (or synced from Advanced Mode), False + if it is empty or was auto-filled from the first video's folder.""" + return self._user_selected_dir + def on_show(self): if ( not self._user_selected_dir @@ -531,6 +620,11 @@ def on_show(self): self._on_mode_changed() def validate(self) -> ty.Optional[str]: + if self.output_mode in FFMPEG_OUTPUT_MODES and not self._ffmpeg_available: + return ( + "The selected output mode requires ffmpeg, which was not found. " + "Install ffmpeg or choose another output mode." + ) if not self.scan_only and not self._directory: return "Select an output directory, or choose Scan Only mode." return None @@ -547,11 +641,15 @@ def __init__( videos_step: _VideosStep, preset_step: _PresetStep, output_step: _OutputStep, + get_overrides: ty.Optional[ty.Callable[[], ty.Dict[str, ty.Any]]] = None, ): + """`get_overrides` returns the settings from Advanced Mode (start/end time, + regions) that will be applied to the scan, so they can be listed here.""" self.frame = ttk.Frame(root) self._videos_step = videos_step self._preset_step = preset_step self._output_step = output_step + self._get_overrides = get_overrides self._summary = tk.Label(self.frame, justify=tk.LEFT, anchor=tk.NW) self.frame.rowconfigure(0, weight=1) self.frame.columnconfigure(0, weight=1) @@ -575,8 +673,22 @@ def on_show(self): ] if not self._output_step.scan_only: lines.append(f"Directory: {self._output_step.directory}") + lines += self._override_lines() self._summary["text"] = "\n".join(lines) + def _override_lines(self) -> ty.List[str]: + overrides = self._get_overrides() if self._get_overrides is not None else {} + if not overrides: + return [] + lines = ["", "From Advanced Mode:"] + if "start-time" in overrides: + lines.append(f" Start Time: {overrides['start-time']}") + if "end-time" in overrides: + lines.append(f" End Time: {overrides['end-time']}") + if "regions" in overrides: + lines.append(f" Regions: {len(overrides['regions'])}") + return lines + def validate(self) -> ty.Optional[str]: return None @@ -614,7 +726,7 @@ def validate(self) -> ty.Optional[str]: class ScanWizard: """Step-by-step scan workflow shown in place of the main window. Created by the - `Application`, which provides callbacks for switching back to classic mode. The + `Application`, which provides callbacks for switching back to Advanced Mode. The scan runs in-place on the final step rather than in a separate window.""" def __init__( @@ -624,10 +736,26 @@ def __init__( on_close: ty.Callable[[], None], on_switch_to_classic: ty.Callable[[ty.List[str]], None], on_preset_applied: ty.Callable[[presets.Preset, ConfigRegistry], None], + startup_mode_var: tk.StringVar, + on_startup_mode_changed: ty.Callable[[], None], + active_preset: ty.Optional[str] = presets.DEFAULT_PRESET_NAME, + on_load_config: ty.Optional[ty.Callable[[str], bool]] = None, + get_scan_overrides: ty.Optional[ + ty.Callable[[str, ty.Optional[ty.Tuple[int, int]]], ty.Dict[str, ty.Any]] + ] = None, ): + """`active_preset` is the preset `settings` corresponds to, if any (see + `_PresetStep`). `on_load_config` loads a config file dropped onto the wizard and + syncs the wizard from it, returning True on success. `get_scan_overrides` returns + the settings made in Advanced Mode that the wizard has no controls for (start/end + time, regions), given the first input video and its resolution (w, h).""" self._settings = settings self._on_close = on_close self._on_switch_to_classic = on_switch_to_classic + self._get_scan_overrides = get_scan_overrides + # Shared with the classic window so both Startup Mode menus stay in sync. + self._startup_mode_var = startup_mode_var + self._on_startup_mode_changed = on_startup_mode_changed self._scan_view: ty.Optional[ScanProgressView] = None self._scanning = False self._browse_in_progress = False @@ -656,10 +784,15 @@ def __init__( self._window, on_browse=self._browse_videos, can_accept_drop=self._can_modify_videos, + on_load_config=on_load_config, + ) + preset_step = _PresetStep( + content, on_preset_applied, base_config=settings.config, active_preset=active_preset ) - preset_step = _PresetStep(content, on_preset_applied, base_config=settings.config) output_step = _OutputStep(content, videos_step=videos_step, settings=settings) - summary_step = _SummaryStep(content, videos_step, preset_step, output_step) + summary_step = _SummaryStep( + content, videos_step, preset_step, output_step, get_overrides=self._scan_overrides + ) scan_step = _ScanStep(content) self._videos_step = videos_step self._preset_step = preset_step @@ -709,6 +842,27 @@ def __init__( def videos(self) -> ty.List[str]: return self._videos_step.input_area.videos + @property + def window(self) -> tk.Toplevel: + return self._window + + @property + def is_visible(self) -> bool: + """True if the wizard window exists and is not withdrawn.""" + try: + return bool(self._window.winfo_exists()) and self._window.state() != "withdrawn" + except tk.TclError: + return False + + def _scan_overrides(self) -> ty.Dict[str, ty.Any]: + """Settings made in Advanced Mode (start/end time, regions) to apply to a scan of + the wizard's videos. Empty if there are no videos or no Advanced Mode settings.""" + get_overrides = getattr(self, "_get_scan_overrides", None) + videos = self.videos + if get_overrides is None or not videos: + return {} + return get_overrides(videos[0], self._videos_step.input_area.first_video_resolution) + def _can_modify_videos(self) -> bool: return not getattr(self, "_scanning", False) and self._step_index != getattr( self, "_scan_index", -1 @@ -768,21 +922,30 @@ def set_videos(self, paths: ty.List[str]): self._videos_step.input_area.add_video(path) self._refresh_form_nav_state() - def sync_from_settings(self, settings: ScanSettings, active_preset: ty.Optional[str] = None): - """Sync wizard preset, threshold, and output choices from classic mode settings.""" - self._preset_step.sync_from_settings(settings, active_preset=active_preset) + def sync_from_settings( + self, + settings: ScanSettings, + active_preset: ty.Optional[str] = None, + config: ty.Optional[ConfigRegistry] = None, + ): + """Sync wizard preset, threshold, and output choices from Advanced Mode settings. + `config`, if given, replaces the wizard's base config (see `_PresetStep`).""" + self._preset_step.sync_from_settings(settings, active_preset=active_preset, config=config) self._output_step.sync_from_settings(settings) if self._step_index == self._summary_index: self._steps[self._summary_index].on_show() def apply_to_classic(self, motion_window, output_area, scan_area): - """Copy the wizard's current threshold and output choices into the classic UI.""" + """Copy the wizard's current threshold and output choices into Advanced Mode.""" motion_window._threshold.set(str(self._preset_step.threshold)) scan_area.scan_only = self._output_step.scan_only if self._output_step.output_mode is not None: output_area._output_mode = self._output_step.output_mode output_area._on_mode_combo_selected() - if self._output_step.directory: + # Only a folder the user chose: copying one auto-filled from the first video would + # come back via `sync_from_settings` as a sticky user choice, so it would no longer + # follow the input videos. + if self._output_step.directory and self._output_step.user_selected_directory: output_area._output_dir = self._output_step.directory def show(self): @@ -987,6 +1150,7 @@ def _create_menubar(self): ) file_menu.add_cascade(label="Sort", underline=0, menu=self._build_sort_menu(file_menu)) file_menu.add_command(label="Advanced Mode", underline=0, command=self._on_switch) + add_startup_mode_menu(file_menu, self._startup_mode_var, self._on_startup_mode_changed) file_menu.add_command(label="Quit", underline=0, command=self._on_quit) edit_menu = self._menubar.add_menu("Edit", underline=0) @@ -1080,6 +1244,10 @@ def _start_scan(self): self._last_output_dir = self._output_step.directory else: self._last_output_dir = None + # Start/end time and regions set in Advanced Mode (the wizard has no controls for + # them, and they are shown on the summary step). + for key, value in self._scan_overrides().items(): + settings.set(key, value) finalize_output_names(settings, combine=False) logger.debug(f"wizard settings:\n{settings.app_settings}") @@ -1093,6 +1261,17 @@ def _start_scan(self): parent=self._window, ) return + except (VideoOpenFailure, ValueError, OSError) as ex: + # Setting up the scanner opens the inputs and the output, e.g. inputs with + # different resolutions, a deleted file, ffmpeg missing, a bad output folder. + logger.error(f"Failed to start scan: {ex}", exc_info=True) + tkinter.messagebox.showerror( + title="Scan Error", + message=f"Failed to start scan:\n\n{ex}\n\nSee the log for details " + "(Help > Open Logs).", + parent=self._window, + ) + return self._scan_view = view self._scan_step.set_content(view.frame) self._scanning = True diff --git a/dvr_scan/cli.py b/dvr_scan/cli.py index ad00125a..387c2674 100644 --- a/dvr_scan/cli.py +++ b/dvr_scan/cli.py @@ -132,8 +132,8 @@ def get_cli_parser(user_config: ConfigRegistry): action="append", help=( "[REQUIRED] Path to input video. May specify multiple inputs with the same" - " resolution and framerate, or by specifying a wildcard/glob. Output" - " filenames are generated using the first video name only." + " resolution, or by specifying a wildcard/glob. Multiple inputs are scanned" + " as one continuous video, and each event is named after the video it starts in." ), ) @@ -191,10 +191,9 @@ def get_cli_parser(user_config: ConfigRegistry): "-so", "--scan-only", action="store_true", - default=False, help=( "Only perform motion detection (does not write any files to disk)." - " If set, -m/--output-mode is ignored." + " If set, -m/--output-mode is ignored.%s" % user_config.get_help_string("scan-only") ), ) @@ -315,8 +314,10 @@ def get_cli_parser(user_config: ConfigRegistry): metavar="time", type=timecode_type_check("time"), help=( - "Length of time that must contain motion before triggering a new event. " - "Footage beyond this limit goes to a new event. Can be" + "Maximum length of a single event, including padding. Longer events are split" + " into consecutive events of at most this length; no post-event padding is added" + " at a split, and" + " if motion continues the next event starts where the previous one ended. Can be" " specified as frames (123), seconds (12.3s), or timecode (00:00:01).%s" % user_config.get_help_string("max-event-length") ), @@ -505,9 +506,12 @@ def get_cli_parser(user_config: ConfigRegistry): parser.add_argument( "--thumbnails", metavar="method", - type=str, - default=None, - help=("Produce event thumbnail(s)."), + type=string_type_check(CHOICE_MAP["thumbnails"], False, "method"), + help=( + "Save a thumbnail image for each event. Must be one of: highscore (frame with the" + " highest motion score).%s" + % user_config.get_help_string("thumbnails", show_default=False) + ), ) parser.add_argument( @@ -535,7 +539,6 @@ def get_cli_parser(user_config: ConfigRegistry): "--debug", action="store_true", help=argparse.SUPPRESS, - default=False, ) # TODO: Support both input and output concatenation in ffmpeg mode. For concatenating events, diff --git a/dvr_scan/config.py b/dvr_scan/config.py index 796d6edd..a9ce13fe 100644 --- a/dvr_scan/config.py +++ b/dvr_scan/config.py @@ -18,11 +18,18 @@ in the `dvr_scan` module. """ +import locale import logging +import os +import re +import shutil +import stat import typing as ty from abc import ABC, abstractmethod -from configparser import DEFAULTSECT, ConfigParser, ParsingError +from configparser import DEFAULTSECT, ConfigParser +from configparser import Error as ConfigParserError from pathlib import Path +from tempfile import NamedTemporaryFile from platformdirs import user_config_dir from scenedetect import FrameTimecode @@ -305,6 +312,152 @@ def from_config(config_value: str, default: "RGBValue") -> "RGBValue": USER_CONFIG_FILE_PATH: Path = _CONFIG_FILE_DIR / _CONFIG_FILE_NAME + +_UTF8_BOM = b"\xef\xbb\xbf" + + +def decode_config_bytes(raw: bytes) -> ty.Tuple[str, str]: + """Decode the contents of a config file. Files are decoded as UTF-8 (with or without a + BOM), falling back to the locale encoding for files written by older versions or by + editors using the system code page. Line endings are normalized to LF. + + Returns: + (text, encoding) where `encoding` is the codec to write the file back with: + `utf-8-sig` if the file starts with a UTF-8 BOM, `utf-8`, or the locale encoding. + + Raises: + UnicodeDecodeError: The file is neither valid UTF-8 nor valid in the locale encoding. + """ + try: + text = raw.decode("utf-8-sig") + encoding = "utf-8-sig" if raw.startswith(_UTF8_BOM) else "utf-8" + except UnicodeDecodeError: + encoding = locale.getpreferredencoding(False) + text = raw.decode(encoding) + return text.replace("\r\n", "\n").replace("\r", "\n"), encoding + + +def read_config_file(path: Path) -> ty.Tuple[str, str]: + """Read the config file at `path`. See `decode_config_bytes` for the return value. + + Raises: + OSError: The file could not be read. + UnicodeDecodeError: The file could not be decoded. + """ + return decode_config_bytes(Path(path).read_bytes()) + + +def _existing_newline(path: Path) -> str: + """Line ending style of the existing file at `path` (CRLF if it has any CRLF line + endings, otherwise LF), or the platform default if it does not exist.""" + try: + raw = path.read_bytes() + except OSError: + return os.linesep + return "\r\n" if b"\r\n" in raw else "\n" + + +def atomic_write_text( + path: Path, text: str, encoding: str = "utf-8", newline: ty.Optional[str] = None +): + """Write `text` to `path` atomically (temp file in the same folder, then replace), + creating parent folders as required. `text` must use LF line endings, which are written + as `newline`: by default the style of the existing file at `path` is kept, and the + platform default is used for new files. On POSIX, an existing file's permission bits + are kept. The temporary file is removed if the write fails. + + Raises: + OSError: The file could not be written. + UnicodeError: `text` cannot be represented in `encoding`. + """ + path.parent.mkdir(parents=True, exist_ok=True) + if newline is None: + newline = _existing_newline(path) + tmp = NamedTemporaryFile( + mode="w", + dir=path.parent, + suffix=".tmp", + delete=False, + encoding=encoding, + newline=newline, + ) + tmp_path = Path(tmp.name) + try: + with tmp: + tmp.write(text) + # On Windows the only mode bit is read-only, and copying it would make both the + # replace and the cleanup below fail (leaving the temp file behind). + if os.name != "nt" and path.exists(): + try: + shutil.copymode(path, tmp_path) + except OSError: + pass + tmp_path.replace(path) + except BaseException: + # Never let a cleanup failure mask the original error. + try: + tmp_path.unlink(missing_ok=True) + except OSError: + try: + os.chmod(tmp_path, stat.S_IREAD | stat.S_IWRITE) + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise + + +def set_user_config_option(option: str, value: str, path: ty.Optional[Path] = None) -> Path: + """Set a single `option = value` line in the user config file (or `path`), preserving + every other line and comment. The first active line setting `option` (any case, as + config keys are case-insensitive) is replaced along with any indented continuation + lines; if there is none, the first commented-out one is; otherwise the option is + appended. The file is created if it does not exist. Existing files keep their encoding + (UTF-8 with or without a BOM, or the locale encoding for files that are not valid UTF-8; + see `decode_config_bytes`) and line ending style; new files are written as UTF-8. + + Returns: + Path the option was written to. + + Raises: + OSError: The file could not be read or written. + UnicodeError: The file could not be decoded or `value` encoded. + """ + assert option in CONFIG_MAP + if path is None: + path = USER_CONFIG_FILE_PATH + new_line = f"{option} = {value}" + encoding = "utf-8" + if path.exists(): + text, encoding = read_config_file(path) + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() + else: + # Deferred import: dvr_scan/__init__ may import this module. + import dvr_scan + + lines = ["# DVR-Scan Config File", f"# Created by: DVR-Scan {dvr_scan.__version__}"] + active = re.compile(r"^\s*%s\s*[=:]" % re.escape(option), re.IGNORECASE) + commented = re.compile(r"^\s*#\s*%s\s*[=:]" % re.escape(option), re.IGNORECASE) + index = next((i for i, line in enumerate(lines) if active.match(line)), None) + if index is not None: + # Indented non-empty lines following an option are continuations of its value. + end = index + 1 + while end < len(lines) and lines[end] and lines[end][0] in " \t": + end += 1 + lines[index:end] = [new_line] + else: + index = next((i for i, line in enumerate(lines) if commented.match(line)), None) + if index is not None: + lines[index] = new_line + else: + if lines and lines[-1].strip(): + lines.append("") + lines.append(new_line) + atomic_write_text(path, "\n".join(lines) + "\n", encoding=encoding) + return path + + # TODO: Investigate if centralizing user help strings here would be useful. # It might make CLI help and documentation are easier to create, as well as # for generating a default config file template. @@ -313,8 +466,11 @@ def from_config(config_value: str, default: "RGBValue") -> "RGBValue": CONFIG_MAP: ConfigDict = { # General Options "quiet-mode": False, - # Application (dvr-scan-app only) - "startup-mode": "wizard", + # Application (dvr-scan-app only). The code default is `advanced` so existing installs + # keep the full settings view (Advanced Mode); the shipped config template sets `wizard`, + # so fresh installs (which receive the template) start in the Scan Wizard. See + # app/startup.py. + "startup-mode": "advanced", # Input/Output "encode-args": DEFAULT_ENCODE_ARGS, "ffmpeg-input-args": DEFAULT_FFMPEG_INPUT_ARGS, @@ -390,13 +546,47 @@ def from_config(config_value: str, default: "RGBValue") -> "RGBValue": "verbosity": ["debug", "info", "warning", "error"], "bg-subtractor": ["MOG2", "CNT", "MOG2_CUDA"], "thumbnails": ["highscore"], - "startup-mode": ["wizard", "classic"], + "startup-mode": ["wizard", "advanced"], "timecode-list-format": ["parts", "timecodes", "lines"], } """Mapping of string options which can only be of a particular set of values. We use a list instead of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" +CHOICE_ALIASES: ty.Dict[str, ty.Dict[str, str]] = { + # `classic` was the name of Advanced Mode during 2.0 development. + "startup-mode": {"classic": "advanced"}, +} +"""Legacy values still accepted for options in `CHOICE_MAP`, mapped to the value they are +normalized to. Keys must be lowercase. Aliases are not listed in help text or error messages.""" + + +def normalize_choice(option: str, value: str) -> str: + """Map a legacy alias (any case) of a `CHOICE_MAP` value of `option` to its current + value. Other values are returned unchanged.""" + return CHOICE_ALIASES.get(option, {}).get(value.strip().lower(), value) + + +APP_ONLY_OPTIONS: ty.FrozenSet[str] = frozenset( + { + "quiet-mode", + "startup-mode", + "region-editor", + "timecode-list-format", + "max-events", + "max-event-length", + "thumbnails", + "verbosity", + "save-log", + "max-log-size", + "max-log-files", + "debug", + } +) +"""Config options the GUI has no widget for. When the GUI saves the `Default` preset (the +user config file), these are carried over from the existing file unchanged instead of being +dropped; every other known option is written from the UI state.""" + class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" @@ -456,10 +646,15 @@ def load(self, path: ty.Optional[Path] = None, log_level: int = logging.INFO): self._log(log_level, "Loading user config file:\n %s" % path) # Try to load and parse the config file at `path`. try: - with open(path) as config_file: - config_file_contents = config_file.read() + config_file_contents, _ = read_config_file(path) except OSError as ex: raise ConfigLoadFailure(self._init_log, reason=ex) from ex + except UnicodeError as ex: + self._log( + logging.ERROR, + "Failed to decode config file %s: it must be saved as UTF-8 text." % path, + ) + raise ConfigLoadFailure(self._init_log, reason=ex) from ex self._load_string(config_file_contents, source=str(path)) def load_from_string(self, config_str: str, source: str = ""): @@ -475,7 +670,8 @@ def _load_string(self, contents: str, source: str): config = ConfigParser() try: config.read_string("[%s]\n%s" % (DEFAULTSECT, contents), source=source) - except ParsingError as ex: + except ConfigParserError as ex: + # Covers ParsingError as well as e.g. DuplicateOptionError. raise ConfigLoadFailure(self._init_log, reason=ex) from ex self._parse_config(config) if any(level >= logging.ERROR for level, _ in self._init_log): @@ -567,6 +763,7 @@ def _parse_config( # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: config_value = config.get(DEFAULTSECT, option).replace("\n", " ").strip() + config_value = normalize_choice(option, config_value) if option in CHOICE_MAP: if config_value.lower() not in [ choice.lower() for choice in CHOICE_MAP[option] diff --git a/dvr_scan/controller.py b/dvr_scan/controller.py index 039bddd7..0514cb26 100644 --- a/dvr_scan/controller.py +++ b/dvr_scan/controller.py @@ -28,6 +28,7 @@ from dvr_scan.report import write_events_csv, write_events_json from dvr_scan.scanner import DetectionResult, DetectorType, MotionEvent, MotionScanner, OutputMode from dvr_scan.shared import ScanSettings, init_logging, init_scanner, logfile_path, setup_logger +from dvr_scan.subtractor import cnt_unavailable_message logger = logging.getLogger("dvr_scan") @@ -95,7 +96,7 @@ def parse_settings() -> ty.Optional[ScanSettings]: if args.ignore_user_config: config_load_error = None config = ConfigRegistry() - debug_mode = args.debug + debug_mode = getattr(args, "debug", False) init_logging(args=args, config=config) init_log += [(logging.INFO, "DVR-Scan %s" % dvr_scan.__version__)] if config_load_error and not hasattr(args, "config"): @@ -153,29 +154,59 @@ def parse_settings() -> ty.Optional[ScanSettings]: if not bg_subtractor.value.is_available(): if bg_subtractor == DetectorType.MOG2_CUDA: logger.error("MOG2_CUDA requires a CUDA-enabled build of the `opencv-python` package!") + elif bg_subtractor == DetectorType.CNT: + logger.error(cnt_unavailable_message()) else: - logger.error( - f"Method {bg_subtractor.name} is not available, you may need to run: " - "`pip install opencv-contrib-python`" - ) + logger.error("Method %s is not available.", bg_subtractor.name) return None return settings -def _write_report(settings: ScanSettings, scanner: MotionScanner, result: DetectionResult): - """Write the detected events to the file specified by --report, if set. - - The format is chosen by file extension (.csv or .json, validated at startup). - Relative paths are joined with the output directory when one is set.""" +def _report_path(settings: ScanSettings) -> ty.Optional[Path]: + """Path of the --report file, if set. Relative paths are joined with the output + directory when one is set.""" report = settings.get_arg("report") if not report: - return + return None path = Path(report) if not path.is_absolute(): output_dir = settings.get("output-dir") if output_dir: path = Path(output_dir) / path + return path + + +def _check_report_path(settings: ScanSettings): + """Make sure the --report file (if set) can be written before scanning, so an invalid + path does not fail only after a long scan. The file is not left behind if it did not + already exist. + + Raises: + ValueError: The report file cannot be created or written. + """ + path = _report_path(settings) + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + existed = path.exists() + with open(path, "a"): + pass + if not existed: + path.unlink() + except OSError as ex: + raise ValueError("cannot write report file %s: %s" % (path, ex.strerror or ex)) from ex + + +def _write_report(settings: ScanSettings, scanner: MotionScanner, result: DetectionResult): + """Write the detected events to the file specified by --report, if set. + + The format is chosen by file extension (.csv or .json, validated at startup). + Relative paths are joined with the output directory when one is set.""" + path = _report_path(settings) + if path is None: + return path.parent.mkdir(parents=True, exist_ok=True) if path.suffix.lower() == ".json": with open(path, "w") as file: @@ -231,6 +262,7 @@ def run_dvr_scan( ) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: """Run DVR-Scan scanning logic using validated `settings` from `parse_settings()`.""" + _check_report_path(settings) scanner = init_scanner(settings) # Scan video for motion with specified parameters. diff --git a/dvr_scan/encoder.py b/dvr_scan/encoder.py index 56b78925..2afdf8d9 100644 --- a/dvr_scan/encoder.py +++ b/dvr_scan/encoder.py @@ -60,6 +60,15 @@ RAW_VIDEO_INPUT_ARGS = "-f rawvideo -pix_fmt bgr24" """Arguments describing the raw frames piped to ffmpeg in OutputMode.ENCODE.""" +DEFAULT_PIPE_PIX_FMT = "yuv420p" +"""Output pixel format used in OutputMode.ENCODE unless the encode args specify one. Without +it, ffmpeg keeps full chroma for the bgr24 pipe (e.g. H.264 High 4:4:4 Predictive), which +many players and devices cannot decode.""" + +EVEN_DIMENSIONS_FILTER = "pad=ceil(iw/2)*2:ceil(ih/2)*2" +"""Video filter padding frames to even dimensions, required by 4:2:0 output (e.g. after +-df/--downscale-factor or region cropping yields an odd width or height).""" + # TODO(#89): Add ability to set output name template. OUTPUT_FILE_TEMPLATE = "{VIDEO_NAME}.DSME_{EVENT_NUMBER}.{EXTENSION}" """Template to use for generating output files.""" @@ -349,6 +358,23 @@ def _audio_end_time(source: SourceInfo) -> ty.Optional[Fraction]: return end +def _seek_offset(source: SourceInfo) -> Fraction: + """Offset to add to a time on `source`'s normalized video timeline (0 = first video + frame) to get the equivalent input seek (-ss) position for ffmpeg. + + ffmpeg already adds the container start time to input seeks, so only the difference + between the video stream's start and the container's start is needed (e.g. AAC priming + makes audio start slightly before video). Adding the full video start time instead + would count a nonzero container start time (e.g. DVR .ts files) twice.""" + video = source.video + if video.start_time is None or video.time_base is None: + return Fraction(0) + offset = Fraction(video.start_time) * video.time_base + if source.start_time is not None: + offset -= source.start_time + return offset + + def _drain_stream(stream: ty.IO[bytes], sink: ty.List[str]): """Read `stream` line-by-line into `sink` until EOF (run on a helper thread to prevent the subprocess from blocking on a full stderr pipe).""" @@ -397,12 +423,41 @@ def build_pipe_command( args += ["-filter_complex", concat_filter, "-map", "[outa]"] if audio_inputs: args += ["-shortest"] - # Use split() without a separator so extra whitespace doesn't produce empty args. - args += encode_args.split() + args += _video_encode_args(encode_args, frame_size) args.append(str(output_path)) return args +def _video_encode_args(encode_args: str, frame_size: ty.Tuple[int, int]) -> ty.List[str]: + """Split `encode_args` and add the output pixel format and even-dimension padding. + + `-pix_fmt yuv420p` is added unless the user specified a pixel format. When the frame + size is odd, or the user supplied their own video filter (which may change the size), + a pad filter rounding the size up to even is appended to the video filter chain. A + filter graph (`-filter_complex`/`-lavfi`) cannot be combined with `-vf`, so users of + one are responsible for even output dimensions themselves.""" + # Use split() without a separator so extra whitespace doesn't produce empty args. + args = encode_args.split() + if not any(arg.startswith("-pix_fmt") for arg in args): + args = ["-pix_fmt", DEFAULT_PIPE_PIX_FMT] + args + if any(arg in ("-filter_complex", "-lavfi") for arg in args): + return args + filter_index = next( + ( + index + for index, arg in enumerate(args[:-1]) + if arg in ("-vf", "-filter:v", "-filter:v:0", "-vf:0") + ), + None, + ) + if filter_index is not None: + # Pad after the user's filters, since they may change the frame size. + args[filter_index + 1] += "," + EVEN_DIMENSIONS_FILTER + elif frame_size[0] % 2 or frame_size[1] % 2: + args = ["-vf", EVEN_DIMENSIONS_FILTER] + args + return args + + class FFmpegPipeEncoder(EventEncoder): """Encodes events by piping decoded frames to an ffmpeg subprocess (OutputMode.ENCODE). @@ -505,12 +560,8 @@ def _audio_inputs(self, span: ty.Optional[EventSpan]) -> ty.List[ty.Tuple[Path, if index == span.source_index else Fraction(0) ) - video = source.video - if video.start_time is not None and video.time_base is not None: - # Local times are normalized to the first video frame, but -ss seeks in - # container time - compensate for a nonzero video stream start time. - start += Fraction(video.start_time) * video.time_base - audio_inputs.append((source.path, "%.6f" % float(start))) + start += _seek_offset(source) + audio_inputs.append((source.path, "%.6f" % float(max(start, Fraction(0))))) return audio_inputs def _start_process(self, frame_bgr: np.ndarray, timecode: FrameTimecode): diff --git a/dvr_scan/presets.py b/dvr_scan/presets.py index 0fee8b36..6d65b1cd 100644 --- a/dvr_scan/presets.py +++ b/dvr_scan/presets.py @@ -19,15 +19,27 @@ itself (USER_CONFIG_FILE_PATH). """ +import io import logging import re import typing as ty from collections import OrderedDict +from configparser import DEFAULTSECT, ConfigParser +from configparser import Error as ConfigParserError from dataclasses import dataclass from pathlib import Path -from tempfile import NamedTemporaryFile -from dvr_scan.config import USER_CONFIG_FILE_PATH, ConfigLoadFailure, ConfigRegistry +from dvr_scan.config import ( + APP_ONLY_OPTIONS, + CONFIG_MAP, + DEPRECATED_CONFIG_OPTION, + MIGRATED_CONFIG_OPTION, + USER_CONFIG_FILE_PATH, + ConfigLoadFailure, + ConfigRegistry, + atomic_write_text, + read_config_file, +) if ty.TYPE_CHECKING: from dvr_scan.shared.settings import ScanSettings @@ -175,7 +187,7 @@ def scan_user_presets(base_dir: ty.Optional[Path] = None) -> ty.List[Preset]: # Fully load the file: this enforces the CONFIG_MAP schema, which a bare # ConfigParser parse would not catch. ConfigRegistry().load(path) - except ConfigLoadFailure: + except (ConfigLoadFailure, OSError, UnicodeError): logger.warning("Skipping invalid preset file: %s", path) _PRESET_VALIDATION_CACHE[path] = (*cache_key, False) continue @@ -218,7 +230,8 @@ def save_user_preset( name: str, settings: "ScanSettings", base_dir: ty.Optional[Path] = None ) -> Path: """Write the app settings in `settings` as a preset named `name`, atomically - (temp file + replace). Saving over `Default` updates the user config file. + (temp file + replace). Saving over `Default` updates the user config file, preserving + options the UI does not manage (e.g. `startup-mode`, `verbosity`). Returns: Path the preset was written to. @@ -230,13 +243,35 @@ def save_user_preset( if not is_valid_preset_name(name): raise ValueError(f"invalid preset name: {name!r}") path = preset_path(name, base_dir) - path.parent.mkdir(parents=True, exist_ok=True) - tmp = NamedTemporaryFile(mode="w", dir=path.parent, suffix=".tmp", delete=False) - try: - with tmp: - settings.write_to_file(tmp) - Path(tmp.name).replace(path) - except BaseException: - Path(tmp.name).unlink(missing_ok=True) - raise + if name == DEFAULT_PRESET_NAME: + _preserve_unmanaged_options(settings, path) + buffer = io.StringIO() + settings.write_to_file(buffer) + atomic_write_text(path, buffer.getvalue()) return path + + +def _preserve_unmanaged_options(settings: "ScanSettings", path: Path): + """Carry options from the existing config file at `path` which the GUI has no widget + for (`APP_ONLY_OPTIONS`, e.g. `startup-mode`, `verbosity`), plus options unknown to + this version (written by a newer DVR-Scan and tolerated on load, #242), into + `settings` as their raw text, so saving over the user config does not drop them. + Options the GUI manages are written from the UI state only, so clearing one (e.g. + `output-dir`) sticks. A missing, unreadable or unparsable file is ignored.""" + if not path.exists(): + return + parser = ConfigParser(interpolation=None) + try: + text, _ = read_config_file(path) + parser.read_string("[%s]\n%s" % (DEFAULTSECT, text), source=str(path)) + except (OSError, UnicodeError, ConfigParserError): + logger.debug("Not preserving options from unreadable config file: %s", path) + return + for option, raw in parser[DEFAULTSECT].items(): + if option in settings.app_settings: + continue + if option in DEPRECATED_CONFIG_OPTION or option in MIGRATED_CONFIG_OPTION: + continue + if option in CONFIG_MAP and option not in APP_ONLY_OPTIONS: + continue + settings.set(option, raw.replace("\n", " ").strip()) diff --git a/dvr_scan/scanner.py b/dvr_scan/scanner.py index b60831c0..f489797e 100644 --- a/dvr_scan/scanner.py +++ b/dvr_scan/scanner.py @@ -649,11 +649,9 @@ def scan(self) -> ty.Optional[DetectionResult]: # from the average framerate, but emitted event boundaries are always exact (PTS-based). pre_event_len: int = self._time_before_event.frame_num // (self._frame_skip + 1) min_event_len: int = max(self._min_event_len.frame_num // (self._frame_skip + 1), 1) - max_event_len: int = max(self._max_event_len.frame_num // (self._frame_skip + 1), 1) # Calculations below rely on min_event_len always being >= 1 (cannot be zero) assert min_event_len >= 1, "min_event_len must be at least 1 frame" - assert max_event_len >= 1, "max_event_len must be at least 1 frame" # Presentation duration of one source frame at the average framerate. frame_duration: float = 1.0 / float(self._input.framerate) @@ -715,303 +713,370 @@ def scan(self) -> ty.Optional[DetectionResult]: progress_bar = FakeTqdmObject() if not self._show_progress else self._create_progress_bar() num_frames_to_process = self.frames_remaining + # Upper bound on the length of each event (-Y/--max-event-length), in seconds. Events + # are split before any frame which would exceed it, so it must cover at least one + # processed frame (including skipped frames). + max_event_seconds: float = max( + self._max_event_len.seconds, frame_duration * (1 + self._frame_skip) + ) + + self._decode_thread_exception = None + self._encode_thread_exception = None decode_queue = queue.Queue(MAX_DECODE_QUEUE_SIZE) + encode_queue = queue.Queue(MAX_ENCODE_QUEUE_SIZE) decode_thread = threading.Thread( target=MotionScanner._decode_thread, args=(self, decode_queue), daemon=True ) - decode_thread.start() - - self._encoder = self._create_encoder() - self._mask_writer = ( - MaskWriter( - path=self._mask_file, - fourcc=self._fourcc, - frame_rate=self._effective_frame_rate, - output_dir=self._output_dir, - encode_args=self._encode_args or DEFAULT_ENCODE_ARGS, - ) - if self._mask_file is not None - else None - ) - encode_thread = None - if self._output_mode != OutputMode.SCAN_ONLY or self._mask_file is not None: - encode_queue = queue.Queue(MAX_ENCODE_QUEUE_SIZE) - encode_thread = threading.Thread( - target=MotionScanner._encode_thread, - args=(self, encode_queue), - daemon=True, - ) - encode_thread.start() - - if self._scan_started: - self._scan_started(num_frames=num_frames_to_process) + encode_thread: ty.Optional[threading.Thread] = None + + def finish_event( + start: FrameTimecode, end: FrameTimecode, can_stop: bool = True + ) -> FrameTimecode: + """Record the in-progress event as ending at `end` and send it to the encode + thread (after any of its frames). Returns the (clamped) end of the event.""" + if end.seconds < start.seconds: + logger.debug("clamping event end %s to start %s", end, start) + end = start + logger.debug("event %d high score %f" % (1 + len(event_list), self._highscore)) + if self._thumbnails == "highscore": + # This event has not been appended to event_list yet. + self._write_highscore_thumbnail(start, 1 + len(event_list)) + # Reset even when thumbnails are off so the score/frame don't carry over into + # the next event (#267). + self._highscore = 0 + self._highframe = None + event_list.append(MotionEvent(start=start, end=end)) + if self._output_mode != OutputMode.SCAN_ONLY: + encode_queue.put(MotionEvent(start=start, end=end)) + if can_stop and self._max_events > 0 and len(event_list) >= self._max_events: + logger.info( + "Stopping scan, reached maximum number of events (%d).", + self._max_events, + ) + # Set before `_stop` so `is_stopped()` never reports this completion as an + # interruption. + self._max_events_reached = True + self._stop.set() + return end - # TODO: The main scanning loop should be refactored into a state machine. The main loop - # # has grown far too complicated and is difficult to maintain. - while not self._stop.is_set(): - if self._processed_frame: - num_events = len(event_list) - if in_motion_event: - num_events += 1 - self._processed_frame(progress_bar=progress_bar, num_events=num_events) - # Keep polling decode queue until it's empty (signaled via None). - frame: ty.Optional[DecodeEvent] = decode_queue.get() - if frame is None: - break - assert frame.frame_bgr is not None - last_frame_time = frame.timecode - frame_size = (frame.frame_bgr.shape[1], frame.frame_bgr.shape[0]) - if frame_size != self._input.resolution: - time = frame.timecode - video_res = self._input.resolution - logger.warning( - f"WARNING: Frame {time.frame_num} [{time.get_timecode()}] has unexpected size: " - f"{frame_size[0]}x{frame_size[1]}, expected {video_res[0]}x{video_res[1]}" + decode_thread.start() + # Any exception below must still stop and join the decode/encode threads, so the + # encoders are closed (ffmpeg finalizes/unlocks its output files) before re-raising. + try: + self._encoder = self._create_encoder() + self._mask_writer = ( + MaskWriter( + path=self._mask_file, + fourcc=self._fourcc, + frame_rate=self._effective_frame_rate, + output_dir=self._output_dir, + encode_args=self._encode_args or DEFAULT_ENCODE_ARGS, ) - result = detector.update(frame.frame_bgr) - frame_score = result.score - - if test_width_height_area: - box_width = cv2.boundingRect(result.subtracted)[2] * self._downscale_factor - box_height = cv2.boundingRect(result.subtracted)[3] * self._downscale_factor - width_fraction = box_width / self._input.resolution[0] - height_fraction = box_height / self._input.resolution[1] - area_fraction = width_fraction * height_fraction - - # TODO: The rejection filter can be disabled by providing values > 255.0, but we should - # provide a better method of disabling it. It might also be useful to allow users to - # specify the amount of consecutive frames the filter can be active for. - if frame_score >= self._max_threshold: - frame_score = 0 - if test_width_height_area and ( - area_fraction > self._max_area - or width_fraction > self._max_width - or height_fraction > self._max_height - ): - frame_score = 0 - max_event_len_reached = False - if in_motion_event and event_start is not None: - if (frame.timecode.frame_num - event_start.frame_num) > max_event_len: - max_event_len_reached = True - frame_score = 0 - above_threshold = frame_score >= self._threshold - - event_window.append(frame_score) - # The first frame fed to the detector can sometimes produce unreliable results due - # to it not having any previous information to compare against. - if not processed_first_frame: - above_threshold = False - processed_first_frame = True - event_window = event_window[-min_event_len:] - - if above_threshold: - if frame_score > self._highscore: - self._highscore = frame_score - # Copy the frame since the encode thread draws overlays in-place on - # the original when writing events. - self._highframe = frame.frame_bgr.copy() - elif not in_motion_event: - # Outside of a motion event, a frame below the threshold means the current - # streak of frames can no longer start an event, so any thumbnail candidate - # captured from it must be discarded (#268). - self._highscore = 0 - self._highframe = None - - bounding_box = None - # TODO: Only call clear() when we exit the current motion event. - # TODO: Include frames below the threshold for smoothing, or push a sentinel - # value to update() to compensate the amount of smoothing accordingly. - if self._bounding_box: - bounding_box = ( - self._bounding_box.update(result.subtracted) - if above_threshold - else self._bounding_box.clear() + if self._mask_file is not None + else None + ) + if self._output_mode != OutputMode.SCAN_ONLY or self._mask_file is not None: + encode_thread = threading.Thread( + target=MotionScanner._encode_thread, + args=(self, encode_queue), + daemon=True, ) + encode_thread.start() - if self._mask_file and not self._stop.is_set(): - encode_queue.put( - MotionMaskEvent( - motion_mask=np.ma.filled(result.masked, fill_value=63), - timecode=frame.timecode, - score=frame_score, - bounding_box=bounding_box, + if self._scan_started: + self._scan_started(num_frames=num_frames_to_process) + + # TODO: The main scanning loop should be refactored into a state machine. The main + # loop has grown far too complicated and is difficult to maintain. + while not self._stop.is_set(): + if self._processed_frame: + num_events = len(event_list) + if in_motion_event: + num_events += 1 + self._processed_frame(progress_bar=progress_bar, num_events=num_events) + # Keep polling decode queue until it's empty (signaled via None). + frame: ty.Optional[DecodeEvent] = decode_queue.get() + if frame is None: + break + assert frame.frame_bgr is not None + last_frame_time = frame.timecode + frame_size = (frame.frame_bgr.shape[1], frame.frame_bgr.shape[0]) + if frame_size != self._input.resolution: + time = frame.timecode + video_res = self._input.resolution + logger.warning( + f"WARNING: Frame {time.frame_num} [{time.get_timecode()}] has unexpected " + f"size: {frame_size[0]}x{frame_size[1]}, expected " + f"{video_res[0]}x{video_res[1]}" ) - ) + result = detector.update(frame.frame_bgr) + frame_score = result.score + + if test_width_height_area: + box_width = cv2.boundingRect(result.subtracted)[2] * self._downscale_factor + box_height = cv2.boundingRect(result.subtracted)[3] * self._downscale_factor + width_fraction = box_width / self._input.resolution[0] + height_fraction = box_height / self._input.resolution[1] + area_fraction = width_fraction * height_fraction + + # TODO: The rejection filter can be disabled by providing values > 255.0, but we + # should provide a better method of disabling it. It might also be useful to + # allow users to specify the amount of consecutive frames the filter can be + # active for. + if frame_score >= self._max_threshold: + frame_score = 0 + if test_width_height_area and ( + area_fraction > self._max_area + or width_fraction > self._max_width + or height_fraction > self._max_height + ): + frame_score = 0 + above_threshold = frame_score >= self._threshold + + event_window.append(frame_score) + # The first frame fed to the detector can sometimes produce unreliable results + # due to it not having any previous information to compare against. + if not processed_first_frame: + above_threshold = False + processed_first_frame = True + event_window = event_window[-min_event_len:] + + # Split events at -Y/--max-event-length on the first frame which would extend + # them past it (including skipped frames). The event ends where this frame + # starts (no post-event padding is added), and if there is still motion, this + # frame starts the next event. Done before tracking the high score so this + # frame counts towards the next event's thumbnail. + if in_motion_event and ( + frame.timecode + frame_duration * (1 + self._frame_skip) + > event_start + max_event_seconds + ): + if above_threshold: + # Motion is ongoing, so frames held while waiting out the merge window + # belong to this event, which now ends exactly where the next begins. + for held_frame in post_motion_frames: + if self._stop.is_set(): + break + encode_queue.put(held_frame) + post_motion_frames = [] + split_end = frame.timecode + else: + padded_end = last_motion_time + ( + post_event_duration + frame_duration * (1 + self._frame_skip) + ) + split_end = frame.timecode if frame.timecode < padded_end else padded_end + event_end = finish_event(event_start, split_end) + buffered_frames = post_motion_frames[-buff_len:] + post_motion_frames = [] + if above_threshold and not self._stop.is_set(): + event_start = frame.timecode + buffered_frames = [] + event_window = [] + progress_bar.set_description( + PROGRESS_BAR_DESCRIPTION % (1 + len(event_list)), refresh=False + ) + else: + in_motion_event = False - # Last frame was part of a motion event, or still within the merge window. - if in_motion_event: - hold_frame = False - # If this frame still has motion, reset the merge window. if above_threshold: - last_motion_time = frame.timecode - # Motion resumed within the merge window, so any frames we held - # beyond the post-event padding are part of this event after all. - for encode_frame in post_motion_frames: - # We have to be careful here. Since we're putting multiple items - # into the queue, we have to keep making sure the encode thread - # is running. Otherwise, the queue will never empty we will block - # indefinitely here waiting for a spot. - if self._stop.is_set(): - break - encode_queue.put(encode_frame) - post_motion_frames = [] - # Otherwise, we wait until the merge window has passed before ending - # this motion event and start looking for a new one. - # If the event is longer than max_event_len, we end it immediately. - elif ( - frame.timecode.seconds - last_motion_time.seconds - ) >= merge_window_duration or max_event_len_reached: - in_motion_event = False - - logger.debug("event %d high score %f" % (1 + len(event_list), self._highscore)) - if self._thumbnails == "highscore": - # This event has not been appended to event_list yet. - self._write_highscore_thumbnail(event_start, 1 + len(event_list)) - # Reset even when thumbnails are off so the score/frame don't - # carry over into the next event (#267). + if frame_score > self._highscore: + self._highscore = frame_score + # Copy the frame since the encode thread draws overlays in-place on + # the original when writing events. + self._highframe = frame.frame_bgr.copy() + elif not in_motion_event: + # Outside of a motion event, a frame below the threshold means the current + # streak of frames can no longer start an event, so any thumbnail + # candidate captured from it must be discarded (#268). self._highscore = 0 self._highframe = None - # Calculate event end based on the last frame we had with motion plus - # the post event length time. We also need to compensate for the number - # of frames that we skipped that could have had motion, and include the - # presentation duration of the last frame itself. - event_end = last_motion_time + ( - post_event_duration + frame_duration * (1 + self._frame_skip) - ) - # Guaranteed by the monotonic PTS timeline of the input (#254). - assert event_end.seconds >= event_start.seconds, ( - f"event_end {event_end.seconds}s < " - + f"event_start {event_start.seconds}s!" + bounding_box = None + # TODO: Only call clear() when we exit the current motion event. + # TODO: Include frames below the threshold for smoothing, or push a sentinel + # value to update() to compensate the amount of smoothing accordingly. + if self._bounding_box: + bounding_box = ( + self._bounding_box.update(result.subtracted) + if above_threshold + else self._bounding_box.clear() ) - event_list.append(MotionEvent(start=event_start, end=event_end)) - if self._output_mode != OutputMode.SCAN_ONLY: - encode_queue.put(MotionEvent(start=event_start, end=event_end)) - # Frames held beyond the post-event padding are not part of this - # event, but may fall within the next event's pre-event window. - buffered_frames = post_motion_frames[-buff_len:] - post_motion_frames = [] - if self._max_events > 0 and len(event_list) >= self._max_events: - logger.info( - "Stopping scan, reached maximum number of events (%d).", - self._max_events, + + if self._mask_file and not self._stop.is_set(): + encode_queue.put( + MotionMaskEvent( + motion_mask=np.ma.filled(result.masked, fill_value=63), + timecode=frame.timecode, + score=frame_score, + bounding_box=bounding_box, ) - # Set before `_stop` so `is_stopped()` never reports this - # completion as an interruption. - self._max_events_reached = True - self._stop.set() - else: - # No motion, but still within the merge window. Frames inside the - # post-event padding are part of the output clip whether or not the - # event continues, so they can be sent for encoding right away; any - # frames past that point are held until we know. - hold_frame = frame.timecode.seconds >= last_motion_time.seconds + ( - post_event_duration + frame_duration * (1 + self._frame_skip) ) - # Send frame to encode thread. - if in_motion_event and self._encoder is not None and self._encoder.STREAMS_FRAMES: - encode_frame = EncodeFrameEvent( + encode_frame = ( + EncodeFrameEvent( frame_bgr=frame.frame_bgr, timecode=frame.timecode, bounding_box=bounding_box, score=frame_score, ) - if hold_frame: - post_motion_frames.append(encode_frame) + if self._encoder is not None and self._encoder.STREAMS_FRAMES + else None + ) + + # Last frame was part of a motion event, or still within the merge window. + if in_motion_event: + hold_frame = False + # Exact (PTS-based) end of the event if it doesn't continue: the last frame + # with motion plus the post event length time. We also need to compensate + # for the number of frames that we skipped that could have had motion, and + # include the presentation duration of the last frame itself. + padded_end = last_motion_time + ( + post_event_duration + frame_duration * (1 + self._frame_skip) + ) + # If this frame still has motion, reset the merge window. + if above_threshold: + last_motion_time = frame.timecode + # Motion resumed within the merge window, so any frames we held + # beyond the post-event padding are part of this event after all. + for held_frame in post_motion_frames: + # We have to be careful here. Since we're putting multiple items + # into the queue, we have to keep making sure the encode thread + # is running. Otherwise, the queue will never empty we will block + # indefinitely here waiting for a spot. + if self._stop.is_set(): + break + encode_queue.put(held_frame) + post_motion_frames = [] + # Otherwise, we wait until the merge window has passed before ending + # this motion event and start looking for a new one. + elif ( + frame.timecode.seconds - last_motion_time.seconds + ) >= merge_window_duration: + in_motion_event = False + if encode_frame is not None: + # The frame which ends the event is still part of its clip if it + # starts before the event's end (compared on exact PTS, as the + # window check above is subject to float rounding); otherwise it + # may fall within the next event's pre-event window. + if frame.timecode < padded_end: + encode_queue.put(encode_frame) + else: + post_motion_frames.append(encode_frame) + event_end = finish_event(event_start, padded_end) + # Frames held beyond the post-event padding are not part of this + # event, but may fall within the next event's pre-event window. + buffered_frames = post_motion_frames[-buff_len:] + post_motion_frames = [] else: - encode_queue.put(encode_frame) - # Not already in a motion event, look for a new one. - else: - # Buffer the required amount of frames and overlay data until we find an event. - if self._encoder is not None and self._encoder.STREAMS_FRAMES: - buffered_frames.append( - EncodeFrameEvent( - frame_bgr=frame.frame_bgr, - timecode=frame.timecode, - bounding_box=bounding_box, - score=frame_score, + # No motion, but still within the merge window. Frames inside the + # post-event padding are part of the output clip whether or not the + # event continues, so they can be sent for encoding right away; any + # frames past that point are held until we know. + hold_frame = frame.timecode >= padded_end + + # Send frame to encode thread. + if in_motion_event and encode_frame is not None: + if hold_frame: + post_motion_frames.append(encode_frame) + else: + encode_queue.put(encode_frame) + # Not already in a motion event, look for a new one. + elif not self._stop.is_set(): + # Buffer the required amount of frames and overlay data until we find an + # event. + if encode_frame is not None: + buffered_frames.append(encode_frame) + buffered_frames = buffered_frames[-buff_len:] + # Start a new event once all frames in the event window have motion. + if len(event_window) >= min_event_len and all( + score >= self._threshold for score in event_window + ): + in_motion_event = True + progress_bar.set_description( + PROGRESS_BAR_DESCRIPTION % (1 + len(event_list)), refresh=False ) - ) - buffered_frames = buffered_frames[-buff_len:] - # Start a new event once all frames in the event window have motion. - if len(event_window) >= min_event_len and all( - score >= self._threshold for score in event_window - ): - in_motion_event = True - progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % (1 + len(event_list)), refresh=False - ) - event_window = [] - last_motion_time = frame.timecode - - # Shift the event start back to include the pre-event and min-event - # windows, clamped so events never overlap the previous event or the - # start of the scan. Includes the presentation duration of this frame. - time_since_last_event = ( - frame.timecode.seconds + frame_duration - event_end.seconds - ) - shift_amount = min(time_since_last_event, start_event_shift) - event_start = frame.timecode + (frame_duration - shift_amount) - if event_start.seconds < scan_start_time.seconds: - event_start = scan_start_time - # Send buffered frames to encode thread. - for encode_frame in buffered_frames: - # We have to be careful here. Since we're putting multiple items - # into the queue, we have to keep making sure the encode thread - # is running. Otherwise, the queue will never empty we will block - # indefinitely here waiting for a spot. - if self._stop.is_set(): - break - encode_queue.put(encode_frame) - buffered_frames = [] - - frames_processed += 1 + self._frame_skip - progress_bar.update(1 + self._frame_skip) - - # Close the progress bar before producing any more output. - progress_bar.close() - - # Unblock any remaining puts, wait for decode thread, and re-raise any exceptions. - while not decode_queue.empty(): - _ = decode_queue.get_nowait() - decode_thread.join() - if self._decode_thread_exception is not None: - raise self._decode_thread_exception[1].with_traceback(self._decode_thread_exception[2]) - - # Video ended, finished processing frames. If we're still in a motion event, - # compute the duration and ending timecode and add it to the event list. We can't - # query the input for a PTS at this point (OpenCV resets CAP_PROP_POS_MSEC to 0 at - # end of video), so use the last processed frame's exact presentation time plus its - # presentation duration (including any frames we skipped), clamped to the - # post-event padding when the video ends partway through the merge window. - if in_motion_event and not self._stop.is_set(): - event_end = last_frame_time + frame_duration * (1 + self._frame_skip) - padded_end = last_motion_time + ( - post_event_duration + frame_duration * (1 + self._frame_skip) - ) - if padded_end.seconds < event_end.seconds: - event_end = padded_end - event_list.append(MotionEvent(start=event_start, end=event_end)) - - logger.debug("event %d high score %f" % (len(event_list), self._highscore)) - if self._thumbnails == "highscore": - # The final event was just appended to event_list above. - self._write_highscore_thumbnail(event_start, len(event_list)) - self._highscore = 0 - self._highframe = None + event_window = [] + last_motion_time = frame.timecode + + # Shift the event start back to include the pre-event and min-event + # windows, clamped so events never overlap the previous event or the + # start of the scan. Includes the presentation duration of this frame. + time_since_last_event = ( + frame.timecode.seconds + frame_duration - event_end.seconds + ) + shift_amount = min(time_since_last_event, start_event_shift) + event_start = frame.timecode + (frame_duration - shift_amount) + if event_start.seconds < scan_start_time.seconds: + event_start = scan_start_time + # -Y/--max-event-length also bounds the pre-roll: when the pre-event + # window is at least as long as the limit, start the event so the + # current frame (including skipped frames) still fits within it. + frame_step = frame_duration * (1 + self._frame_skip) + if ( + event_start.seconds + max_event_seconds + < frame.timecode.seconds + frame_step + ): + event_start = frame.timecode + (frame_step - max_event_seconds) + buffered_frames = [ + buffered_frame + for buffered_frame in buffered_frames + if buffered_frame.timecode >= event_start + ] + # Send buffered frames to encode thread. + for buffered_frame in buffered_frames: + # We have to be careful here. Since we're putting multiple items + # into the queue, we have to keep making sure the encode thread + # is running. Otherwise, the queue will never empty we will block + # indefinitely here waiting for a spot. + if self._stop.is_set(): + break + encode_queue.put(buffered_frame) + buffered_frames = [] + + frames_processed += 1 + self._frame_skip + progress_bar.update(1 + self._frame_skip) + + # Close the progress bar before producing any more output. + progress_bar.close() + + # Unblock any remaining puts, wait for decode thread, and re-raise any exceptions. + self._join_decode_thread(decode_thread, decode_queue) + if self._decode_thread_exception is not None: + raise self._decode_thread_exception[1].with_traceback( + self._decode_thread_exception[2] + ) - if self._output_mode != OutputMode.SCAN_ONLY: - encode_queue.put(MotionEvent(start=event_start, end=event_end)) - - # Push sentinel to queue, wait for encode thread, and re-raise any exceptions. - if encode_thread is not None: - encode_queue.put(None) - encode_thread.join() - if self._encode_thread_exception is not None: - raise self._encode_thread_exception[1].with_traceback( - self._encode_thread_exception[2] + # Video ended, finished processing frames. If we're still in a motion event, + # compute the duration and ending timecode and add it to the event list. We can't + # query the input for a PTS at this point (OpenCV resets CAP_PROP_POS_MSEC to 0 at + # end of video), so use the last processed frame's exact presentation time plus + # its presentation duration (including any frames we skipped), clamped to the + # post-event padding when the video ends partway through the merge window. + if in_motion_event and not self._stop.is_set(): + event_end = last_frame_time + frame_duration * (1 + self._frame_skip) + padded_end = last_motion_time + ( + post_event_duration + frame_duration * (1 + self._frame_skip) ) + if padded_end.seconds < event_end.seconds: + event_end = padded_end + finish_event(event_start, event_end, can_stop=False) + + # Push sentinel to queue, wait for encode thread, and re-raise any exceptions. + if encode_thread is not None: + self._join_encode_thread(encode_thread, encode_queue) + if self._encode_thread_exception is not None: + raise self._encode_thread_exception[1].with_traceback( + self._encode_thread_exception[2] + ) + except BaseException: + # Make both threads wind down promptly (they are joined below). + self._stop.set() + raise + finally: + # No-ops on success, where both threads have already been joined above. + progress_bar.close() + self._join_decode_thread(decode_thread, decode_queue) + if encode_thread is not None: + self._join_encode_thread(encode_thread, encode_queue) # Display an error if we got more than one decode failure / corrupt frame. # TODO: This will also fire if no frames are decoded. Add a check to make sure @@ -1027,6 +1092,30 @@ def scan(self) -> ty.Optional[DetectionResult]: return DetectionResult(event_list, frames_processed) + @staticmethod + def _join_decode_thread(decode_thread: threading.Thread, decode_queue: queue.Queue): + """Wait for the decode thread to exit, draining its queue so it can't block on a + put (including the `None` it always sends when exiting). Idempotent.""" + while decode_thread.is_alive(): + try: + decode_queue.get(timeout=0.05) + except queue.Empty: + pass + decode_thread.join() + + @staticmethod + def _join_encode_thread(encode_thread: threading.Thread, encode_queue: queue.Queue): + """Send the `None` sentinel to the encode thread and wait for it to finish the queued + work and close the outputs. The put can't block forever if the thread has already + exited (e.g. after an error) with a full queue. Idempotent.""" + while encode_thread.is_alive(): + try: + encode_queue.put(None, timeout=0.05) + break + except queue.Full: + pass + encode_thread.join() + def _presentation_time(self) -> FrameTimecode: """Presentation time of the most recently decoded frame as a PTS-backed timecode (the first frame of the input has a presentation time of 0).""" @@ -1219,10 +1308,21 @@ def _encode_thread(self, encode_queue: queue.Queue): logger.debug(sys.exc_info()) self._encode_thread_exception = sys.exc_info() finally: - if self._encoder is not None: - self._encoder.close() - if self._mask_writer is not None: - self._mask_writer.close() + # Close every output even if closing one fails (e.g. ffmpeg exiting with an error + # while finalizing), keeping the first error to re-raise on the main thread. + for output in (self._encoder, self._mask_writer): + if output is None: + continue + try: + output.close() + except: # noqa: E722 + self._stop.set() + logger.critical( + "Fatal error: Exception raised closing output in encode thread." + ) + logger.debug(sys.exc_info()) + if self._encode_thread_exception is None: + self._encode_thread_exception = sys.exc_info() # Unblock any waiting puts if we stopped early. while not encode_queue.empty(): _ = encode_queue.get_nowait() diff --git a/dvr_scan/subtractor.py b/dvr_scan/subtractor.py index af3a0f3e..3222b588 100644 --- a/dvr_scan/subtractor.py +++ b/dvr_scan/subtractor.py @@ -14,12 +14,43 @@ All current subtractors use algorithms backed by OpenCV, but this is not a requirement. """ +import importlib.metadata from abc import ABC, abstractmethod import cv2 import numpy +def cnt_unavailable_message() -> str: + """Explain why the CNT subtractor (`cv2.bgsegm`) is missing and how to fix it. + + `opencv-contrib-python` and `opencv-python` unpack into the same `cv2` package. When we install + dependencies that depend on either (e.g. `scenedetect`), the last package installed will + overwrite the one already on disk.""" + installed = set() + for name in ("opencv-contrib-python", "opencv-contrib-python-headless"): + try: + importlib.metadata.version(name) + installed.add(name) + except importlib.metadata.PackageNotFoundError: + pass + if not installed: + return ( + "Method CNT requires the contrib OpenCV build, you may need to run: " + "`pip install opencv-contrib-python`" + ) + package = ( + "opencv-contrib-python-headless" + if installed == {"opencv-contrib-python-headless"} + else "opencv-contrib-python" + ) + return ( + f"Method CNT is not available: `{package}` is installed, but the regular OpenCV " + "package was installed over it and replaced the contrib modules (cv2.bgsegm). " + f"To restore them, run: `pip install --force-reinstall --no-deps {package}`" + ) + + class Subtractor(ABC): """Provides a consistent interface for mapping sequences of video frames to masks of pixels containing motion on each frame (currently via background subtraction).""" diff --git a/dvr_scan/video_input.py b/dvr_scan/video_input.py index b72a60b1..1c0676d4 100644 --- a/dvr_scan/video_input.py +++ b/dvr_scan/video_input.py @@ -92,6 +92,9 @@ class SourceInfo: has_subtitles: bool duration: Fraction """Duration of the video stream in seconds (exact rational value).""" + start_time: ty.Optional[Fraction] = None + """Container start time in seconds (the earliest start of any stream), or None if + unknown. ffmpeg offsets input seeks (-ss) by this value.""" EventSpan = SourceSpan @@ -167,6 +170,9 @@ def probe_source(path: ty.Union[str, Path]) -> SourceInfo: audio=audio_info, has_subtitles=len(container.streams.subtitles) > 0, duration=duration, + start_time=Fraction(container.start_time, av.time_base) + if container.start_time is not None + else None, ) finally: container.close() diff --git a/pyproject.toml b/pyproject.toml index 99aa11a4..cf3c2221 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ dependencies = [ "numpy", "platformdirs", "scenedetect[pyav]~=0.7.2", + # video_input.py imports av directly (probing, audio stream info); keep it explicit + # even though scenedetect[pyav] also pulls it in. + "av", "tqdm", "pillow", "opencv-contrib-python", diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 60485ee2..7e08d444 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -38,6 +38,7 @@ import argparse import json import os +import re import subprocess import sys import tempfile @@ -349,8 +350,33 @@ def _output(result: subprocess.CompletedProcess) -> str: return f" stdout: {result.stdout.strip()[:2000]}\n stderr: {result.stderr.strip()[:2000]}" +BUNDLED_CONFIG = "dvr-scan.cfg" +STARTUP_MODE_LINE = re.compile(r"^\s*startup-mode\s*=\s*(\S+)\s*$", re.IGNORECASE | re.MULTILINE) + + +def check_bundled_config(portable_zip: Path) -> None: + """Step 5: the bundled config template (installed to the user config folder by the MSI + and shipped at the root of the portable .zip) must contain an active + `startup-mode = wizard` line so fresh installs start in the Scan Wizard. The code + default is `advanced`, so a template without the line would silently change what + new users see.""" + section("Bundled config template") + with zipfile.ZipFile(portable_zip) as zf: + names = set(zf.namelist()) + if BUNDLED_CONFIG not in names: + fail(f"{BUNDLED_CONFIG} not found at root of {portable_zip.name}") + text = zf.read(BUNDLED_CONFIG).decode("utf-8-sig") + modes = [m.group(1).lower() for m in STARTUP_MODE_LINE.finditer(text)] + if modes != ["wizard"]: + fail( + f"{BUNDLED_CONFIG} must have exactly one active `startup-mode = wizard` line, " + f"found: {modes or 'none'}" + ) + print(f" {BUNDLED_CONFIG}: startup-mode = wizard") + + def check_frozen_exe(portable_zip: Path) -> None: - """Step 5: extract portable .zip, run both .exes, verify version, package and + """Step 6: extract portable .zip, run both .exes, verify version, package and feature detection, smoke scans, and clean error path.""" section("Frozen .exe smoke tests") if sys.platform != "win32": @@ -483,6 +509,7 @@ def run_all_checks(staging: Path, allow_unsigned: bool = False) -> None: check_signatures(portable_zip, msi, allow_unsigned=allow_unsigned) sevenz = find_7zip() check_msi_zip_parity(portable_zip, msi, sevenz) + check_bundled_config(portable_zip) check_frozen_exe(portable_zip) print() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9abfb19c..00000000 --- a/setup.cfg +++ /dev/null @@ -1,78 +0,0 @@ -# -# DVR-Scan: Video Motion Event Detection & Extraction Tool -# -------------------------------------------------------------- -# [ Site: https://www.dvr-scan.com/ ] -# [ Repo: https://github.com/Breakthrough/DVR-Scan ] -# -# Copyright (C) 2016 Brandon Castellano . -# - -[metadata] -name = DVR-Scan -version = attr: dvr_scan.__version__ -license = BSD 2-Clause License -author = Brandon Castellano -author_email = brandon248@gmail.com -description = Tool for finding and extracting motion events in video files (e.g. security camera footage). -long_description = file: dist/package-info.rst -long_description_content_type = text/x-rst -url = https://www.dvr-scan.com/ -project_urls = - Homepage = https://www.dvr-scan.com/ - Repository = https://github.com/Breakthrough/DVR-Scan - Documentation = https://www.dvr-scan.com/docs - Bug Tracker = https://github.com/Breakthrough/DVR-Scan/issues -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Environment :: Console :: Curses - Intended Audience :: Developers - Intended Audience :: End Users/Desktop - Intended Audience :: System Administrators - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Programming Language :: Python :: 3.13 - Topic :: Multimedia :: Video - Topic :: Multimedia :: Video :: Conversion - Topic :: Multimedia :: Video :: Non-Linear Editor - Topic :: Utilities -keywords = video computer-vision analysis - -[options] -install_requires = - numpy - platformdirs - scenedetect - screeninfo - tqdm -# TODO(#257): Unpin OpenCV versions once we figure out why frame numbers are mismatching in tests. - opencv-contrib-python<4.13 - pillow -packages = -# Main application - dvr_scan - dvr_scan.app - dvr_scan.shared -# Documentation (minified HTML without images to keep package download size small) - dvr_scan.docs - dvr_scan.docs.assets - dvr_scan.docs.assets.javascripts - dvr_scan.docs.assets.stylesheets -python_requires = >=3.10 -include_package_data = True - -[options.entry_points] -console_scripts = - dvr-scan = dvr_scan.__main__:main - dvr-scan-app = dvr_scan.app.__main__:main - -[aliases] -test = pytest - -[tool:pytest] -addopts = --verbose -python_files = tests/*.py diff --git a/tests/resources/audio_start_offset.mp4 b/tests/resources/audio_start_offset.mp4 new file mode 100644 index 00000000..aa2ec78c Binary files /dev/null and b/tests/resources/audio_start_offset.mp4 differ diff --git a/tests/test_application.py b/tests/test_application.py index d8be9b17..5a2c25d4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -81,9 +81,18 @@ class _FakeRegionEditor: prompt_result = True # Return value of prompt_save_on_scan (False = user cancelled). def __init__( - self, frame, initial_shapes, initial_scale, debug_mode, video_path, save_path, on_close=None + self, + frame, + initial_shapes, + initial_scale, + debug_mode, + video_path, + save_path, + on_close=None, + initial_frame_num=0, ): self.frame = frame + self.initial_frame_num = initial_frame_num self.initial_shapes = initial_shapes self.video_path = video_path self._regions = list(initial_shapes) if initial_shapes else [] @@ -375,6 +384,7 @@ def test_region_editor_seeks_to_start_time_and_rebuilds_on_change(area, monkeypa area._on_edit_regions() first = area._region_editor assert np.array_equal(first.frame, frame_0) + assert first.initial_frame_num == 0 first._regions = list(SHAPES) area._on_region_editor_close() @@ -386,6 +396,8 @@ def test_region_editor_seeks_to_start_time_and_rebuilds_on_change(area, monkeypa assert second is not first assert np.array_equal(second.frame, frame_60) assert second.shapes == SHAPES + # Frame stepping in the editor continues from the start time, not frame 0. + assert second.initial_frame_num == 60 def test_region_editor_seek_and_step_frame(monkeypatch): @@ -422,6 +434,34 @@ def test_region_editor_seek_and_step_frame(monkeypatch): assert np.array_equal(editor._source_frame, frames_by_num[2]) +def test_region_editor_steps_from_initial_frame_num(monkeypatch): + """An editor opened on the frame at the start time steps relative to that frame.""" + import dvr_scan.app.region_editor as region_editor_mod + from dvr_scan.app.region_editor import RegionEditor + + frames_by_num = {i: _frame(64, 48, i + 10) for i in range(5)} + fake_video = _FakeVideo( + lambda target: frames_by_num[target.frame_num if target else 0], + frame_rate=10.0, + total_frames=5, + ) + monkeypatch.setattr(region_editor_mod, "open_video", lambda path, backend=None: fake_video) + + editor = RegionEditor( + frame=frames_by_num[3], + initial_shapes=SHAPES, + initial_scale=1, + debug_mode=False, + video_path="a.mp4", + save_path=None, + initial_frame_num=3, + ) + assert editor._current_frame_num == 3 + assert editor.step_frame(-1) + assert editor._current_frame_num == 2 + assert np.array_equal(editor._source_frame, frames_by_num[2]) + + def test_application_on_preset_selected_ignores_noop(monkeypatch): import dvr_scan.presets as presets from dvr_scan.app.application import Application @@ -521,3 +561,464 @@ def test_input_area_key_handlers_act_and_break(): assert area._on_remove_key(object()) == "break" assert tree.get_children() == [] + + +class _FakeStringVar: + def __init__(self, value: str): + self.value = value + + def get(self) -> str: + return self.value + + def set(self, value: str): + self.value = value + + +def _startup_mode_app(default: str): + """A Tk-free Application with only the startup mode state set.""" + from dvr_scan.app.application import Application + + app = Application.__new__(Application) + app._saved_startup_mode = default + app._startup_mode_var = _FakeStringVar(default) + return app + + +def test_startup_mode_change_is_saved(monkeypatch): + app = _startup_mode_app("advanced") + saved = [] + monkeypatch.setattr(application, "save_startup_mode", lambda mode: saved.append(mode)) + app._startup_mode_var.set("wizard") + app._on_startup_mode_changed() + assert saved == ["wizard"] + assert app._saved_startup_mode == "wizard" + + +def test_startup_mode_unchanged_selection_is_saved_again(monkeypatch): + """Re-selecting the mode this instance last saved still writes it: another running + instance may have changed the config file in the meantime.""" + app = _startup_mode_app("advanced") + saved = [] + monkeypatch.setattr(application, "save_startup_mode", lambda mode: saved.append(mode)) + app._on_startup_mode_changed() + assert saved == ["advanced"] + assert app._saved_startup_mode == "advanced" + + +def test_startup_mode_save_failure_reverts_selection(monkeypatch): + app = _startup_mode_app("advanced") + parent = object() + app._dialog_parent = lambda: parent + + def failing_save(mode): + raise OSError("disk full") + + errors = [] + monkeypatch.setattr(application, "save_startup_mode", failing_save) + monkeypatch.setattr( + application.tkinter.messagebox, "showerror", lambda **kwargs: errors.append(kwargs) + ) + app._startup_mode_var.set("wizard") + app._on_startup_mode_changed() + assert app._startup_mode_var.get() == "advanced" + assert app._saved_startup_mode == "advanced" + assert len(errors) == 1 and errors[0]["title"] == "Startup Mode" + assert errors[0]["parent"] is parent + + +# --- Default preset shown at startup ---------------------------------------------------- + + +def test_loaded_config_is_default_preset(): + import argparse + + from dvr_scan.app.application import loaded_config_is_default_preset + + assert loaded_config_is_default_preset(None) is True + assert loaded_config_is_default_preset(argparse.Namespace(ignore_user_config=False)) is True + assert ( + loaded_config_is_default_preset( + argparse.Namespace(ignore_user_config=False, config="other.cfg") + ) + is False + ) + assert loaded_config_is_default_preset(argparse.Namespace(ignore_user_config=True)) is False + + +# --- Fit main window to screen ---------------------------------------------------------- + + +class _FakeWindow: + def __init__(self, req, screen): + self._req = req + self._screen = screen + self.geometry_calls = [] + + def update_idletasks(self): + pass + + def winfo_reqwidth(self): + return self._req[0] + + def winfo_reqheight(self): + return self._req[1] + + def winfo_screenwidth(self): + return self._screen[0] + + def winfo_screenheight(self): + return self._screen[1] + + def geometry(self, value): + self.geometry_calls.append(value) + + +def test_fit_window_to_screen_noop_when_it_fits(): + from dvr_scan.app.common import fit_window_to_screen + + window = _FakeWindow(req=(1073, 1107), screen=(3440, 1440)) + assert fit_window_to_screen(window) is False + assert window.geometry_calls == [] + + +def test_fit_window_to_screen_shrinks_oversized_dimensions_only(): + from dvr_scan.app.common import fit_window_to_screen + + # 1366x768 laptop: width fits, height does not. + window = _FakeWindow(req=(1073, 1107), screen=(1366, 768)) + assert fit_window_to_screen(window) is True + assert window.geometry_calls == ["1073x648"] + # Both too large. + window = _FakeWindow(req=(1073, 1107), screen=(1024, 600)) + assert fit_window_to_screen(window) is True + assert window.geometry_calls == ["984x480"] + + +# --- Active preset refresh ---------------------------------------------------------------- + + +def test_refresh_active_preset_clears_when_ui_state_changed(): + from dvr_scan.app.application import Application + + app = Application.__new__(Application) + app._active_preset = "Default" + app._preset_snapshot = {"threshold": "0.15"} + app._serialize_ui_state = lambda: {"threshold": "0.3"} + cleared = [] + app._preset_area = type("Area", (), {"set_active": lambda self, name: cleared.append(name)})() + app._refresh_active_preset() + assert app._active_preset is None + assert app._preset_snapshot is None + assert cleared == [None] + + # Unchanged state keeps the preset. + app._active_preset = "Default" + app._preset_snapshot = {"threshold": "0.3"} + app._refresh_active_preset() + assert app._active_preset == "Default" + assert cleared == [None] + + +# --- Settings from Advanced Mode applied to Scan Wizard scans (S3f) ----------------------- + + +def test_scan_overrides_empty_by_default(area): + area._set_region.set(False) + assert area.scan_overrides("a.mp4", (64, 48)) == {} + + +def test_scan_overrides_include_start_and_end_time(area): + area._set_region.set(False) + area._set_time.set(True) + area._start_time.set("00:00:02.000") + area._end_time.set("00:00:05.000") + assert area.scan_overrides("a.mp4", (64, 48)) == { + "start-time": "00:00:02.000", + "end-time": "00:00:05.000", + } + + +def test_scan_overrides_drop_end_time_not_after_start(area): + area._set_region.set(False) + area._set_time.set(True) + area._start_time.set("00:00:05.000") + area._end_time.set("00:00:02.000") + assert area.scan_overrides("a.mp4", None) == {"start-time": "00:00:05.000"} + + +def test_scan_overrides_include_regions_for_matching_video(area, monkeypatch): + _open_editor_with_regions(area, monkeypatch, {"a.mp4": _frame(64, 48, 1)}) + # Same video, or another one with the same (or unknown) resolution. + assert area.scan_overrides("a.mp4", (64, 48)) == {"regions": SHAPES} + assert area.scan_overrides("b.mp4", (64, 48)) == {"regions": SHAPES} + assert area.scan_overrides("b.mp4", None) == {"regions": SHAPES} + # Regions are pixel coordinates: not applied to a video of another size. Unlike + # `update`, this does not discard them from Advanced Mode. + assert area.scan_overrides("b.mp4", (32, 24)) == {} + assert area._regions == SHAPES + # Not applied when Set Regions is unchecked. + area._set_region.set(False) + assert area.scan_overrides("a.mp4", (64, 48)) == {} + + +# --- Release-mode error handling and dialog parents (B4, S5a) ----------------------------- + + +def _error_app(wizard=None): + from dvr_scan.app.application import Application + + app = Application.__new__(Application) + app._root = "root-window" + app._wizard = wizard + return app + + +def test_dialog_parent_is_wizard_only_while_visible(): + from types import SimpleNamespace + + assert _error_app()._dialog_parent() == "root-window" + wizard = SimpleNamespace(is_visible=True, window="wizard-window") + assert _error_app(wizard)._dialog_parent() == "wizard-window" + wizard.is_visible = False + assert _error_app(wizard)._dialog_parent() == "root-window" + + +def test_report_callback_exception_logs_and_shows_error(monkeypatch, caplog): + from types import SimpleNamespace + + app = _error_app(SimpleNamespace(is_visible=True, window="wizard-window")) + shown = [] + monkeypatch.setattr( + application.tkinter.messagebox, "showerror", lambda **kwargs: shown.append(kwargs) + ) + try: + raise RuntimeError("boom") + except RuntimeError as ex: + with caplog.at_level("CRITICAL", logger="dvr_scan"): + app._report_callback_exception(type(ex), ex, ex.__traceback__) + assert "Traceback" in caplog.text and "RuntimeError: boom" in caplog.text + assert len(shown) == 1 + assert "boom" in shown[0]["message"] + assert shown[0]["parent"] == "wizard-window" + + +def test_report_callback_exception_never_raises(monkeypatch): + app = _error_app() + + def failing_showerror(**_kwargs): + raise RuntimeError("no display") + + monkeypatch.setattr(application.tkinter.messagebox, "showerror", failing_showerror) + ex = ValueError("bad") + app._report_callback_exception(type(ex), ex, None) + + +def test_load_config_into_wizard_syncs_wizard(): + synced = [] + + class Wizard: + def sync_from_settings(self, settings, active_preset, config): + synced.append((settings, active_preset, config)) + + app = _error_app(Wizard()) + app._get_config_settings = lambda: "settings" + loaded = [] + + def load(path): + loaded.append(path) + return "config" if path == "good.cfg" else None + + app._load_config_file = load + assert app._load_config_into_wizard("bad.cfg") is False + assert synced == [] + assert app._load_config_into_wizard("good.cfg") is True + # A loaded config file is not a preset; the config itself is passed on too. + assert synced == [("settings", None, "config")] + assert loaded == ["bad.cfg", "good.cfg"] + + +# --- Scan window (scan_window.py) --------------------------------------------------------- + + +@pytest.fixture(scope="module") +def _tk_module_root(): + import tkinter as tk + + try: + root = tk.Tk() + except tk.TclError as ex: + pytest.skip(f"Tk is not available: {ex}") + root.withdraw() + yield root + root.destroy() + + +@pytest.fixture +def tk_root(_tk_module_root): + """A hidden Tk root which records Tcl background errors (e.g. an `after` callback + firing on a destroyed widget) in `::bgerrors` instead of showing a dialog. Shared by + the tests in this module (creating many Tk instances in one process is flaky); child + windows are destroyed after each test.""" + root = _tk_module_root + root.tk.eval("proc bgerror {msg} {lappend ::bgerrors $msg}") + root.tk.eval("unset -nocomplain ::bgerrors") + yield root + for child in root.winfo_children(): + child.destroy() + root.update() + + +def _bgerrors(root) -> str: + root.update() + if root.tk.eval("info exists ::bgerrors") == "0": + return "" + return root.tk.eval("set ::bgerrors") + + +class _FakeScanner: + """Scanner stand-in. With `blocking`, scan() waits until stop() is called.""" + + def __init__(self, blocking: bool = False): + import threading + + self._release = threading.Event() + if not blocking: + self._release.set() + self._stopped = False + + def set_callbacks(self, **_kwargs): + pass + + def scan(self): + self._release.wait(timeout=10.0) + return None + + def is_stopped(self): + return self._stopped + + def stop(self): + self._stopped = True + self._release.set() + + +def _scan_settings(**values): + from dvr_scan.config import ConfigRegistry + from dvr_scan.shared import ScanSettings + + config = ConfigRegistry() + if "config" in values: + config.load_from_string(values.pop("config")) + settings = ScanSettings(args=None, config=config) + settings.set("input", ["a.mp4"]) + for key, value in values.items(): + settings.set(key, value) + return settings + + +def test_scan_progress_view_forces_progress_bar_on_in_quiet_mode(monkeypatch): + """The UI reads its stats from the scanner's tqdm bar, which quiet mode replaces with + a stub lacking `format_dict`; quiet-mode = yes must not break GUI scans (S3d). The + scanner is also set up before any widget is created, so a failure leaves none behind.""" + import dvr_scan.app.scan_window as scan_window + + seen = [] + + def fake_init_scanner(settings): + seen.append(settings.get("quiet-mode")) + raise ValueError("stop here") + + monkeypatch.setattr(scan_window, "init_scanner", fake_init_scanner) + settings = _scan_settings(config="quiet-mode = yes\n") + assert settings.get("quiet-mode") is True + with pytest.raises(ValueError): + # No Tk needed: the parent is never touched if the scanner can't be set up. + scan_window.ScanProgressView(object(), settings, lambda: None, 4) + assert seen == [False] + + +@pytest.mark.parametrize( + "error", [VideoOpenFailure("mismatched"), ValueError("no ffmpeg"), OSError("bad dir")] +) +def test_launch_scan_shows_error_when_scan_setup_fails(monkeypatch, error): + """B4: errors from setting up the scanner must be shown, not silently swallowed.""" + import dvr_scan.app.scan_window as scan_window + + def failing_window(*_args): + raise error + + shown = [] + monkeypatch.setattr(scan_window, "ScanWindow", failing_window) + monkeypatch.setattr(scan_window.messagebox, "showerror", lambda **kwargs: shown.append(kwargs)) + assert scan_window.launch_scan("root", _scan_settings(), lambda: None, 4) is None + assert len(shown) == 1 + assert shown[0]["parent"] == "root" + assert str(error) in shown[0]["message"] + + +def test_scan_window_setup_failure_leaves_no_window(tk_root, monkeypatch): + import dvr_scan.app.scan_window as scan_window + + def failing_init_scanner(_settings): + raise VideoOpenFailure("mismatched resolutions") + + monkeypatch.setattr(scan_window, "init_scanner", failing_init_scanner) + monkeypatch.setattr(scan_window.messagebox, "showerror", lambda **kwargs: None) + assert scan_window.launch_scan(tk_root, _scan_settings(), lambda: None, 4) is None + assert tk_root.winfo_children() == [] + + +def test_scan_progress_view_cancels_update_loop_when_destroyed(tk_root, monkeypatch): + """S3b: a pending UI update must not fire on the destroyed frame (Tk would raise an + "invalid command name" background error).""" + import dvr_scan.app.scan_window as scan_window + + scanner = _FakeScanner(blocking=True) + monkeypatch.setattr(scan_window, "init_scanner", lambda _settings: scanner) + view = scan_window.ScanProgressView(tk_root, _scan_settings(), lambda: None, 4) + view.start() + assert view._after_id is not None + view.frame.destroy() + assert view._after_id is None + view.stop() + tk_root.after(50) + assert _bgerrors(tk_root) == "" + + +def test_scan_window_close_after_scan_does_not_prompt(tk_root, monkeypatch): + """S3i: once the scan has finished, closing the window must not ask "Stop scan?".""" + import dvr_scan.app.scan_window as scan_window + + monkeypatch.setattr(scan_window, "init_scanner", lambda _settings: _FakeScanner()) + closed = [] + window = scan_window.ScanWindow(tk_root, _scan_settings(), lambda: closed.append(1), 4) + assert window._root.protocol("WM_DELETE_WINDOW").endswith("prompt_stop") + window._view._scan_thread.start() + window._view._scan_thread.join(timeout=10.0) + window._on_finished() + assert window._root.protocol("WM_DELETE_WINDOW").endswith("_destroy") + window._destroy() + assert closed == [1] + assert _bgerrors(tk_root) == "" + + +def test_event_table_destroy_cancels_loader_and_releases_video(tk_root, traffic_camera_video): + """S3a: destroying the results table stops the thumbnail loader, whose worker exits + and drops the source video instead of holding it open.""" + from scenedetect import FrameTimecode + + import dvr_scan.app.scan_window as scan_window + from dvr_scan.scanner import MotionEvent + + events = [ + MotionEvent(start=FrameTimecode(10 * i, 25.0), end=FrameTimecode(10 * i + 5, 25.0)) + for i in range(1, 4) + ] + table = scan_window.build_event_table(tk_root, [traffic_camera_video], events) + loader = table.thumbnail_loader + thread = loader._thread + table.destroy() + assert loader._cancelled + thread.join(timeout=10.0) + assert not thread.is_alive() + tk_root.after(100) + assert _bgerrors(tk_root) == "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 5103ddc1..d0574e68 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,16 +15,22 @@ import csv import json +import logging import os import platform import re import shutil import subprocess +import sys import typing as ty import pytest from scenedetect import is_ffmpeg_available +from dvr_scan.cli import get_cli_parser +from dvr_scan.config import ConfigRegistry +from dvr_scan.shared.settings import ScanSettings + # We need to import the OpenCV loader before PySceneDetect as the latter imports OpenCV. from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2 @@ -32,7 +38,9 @@ # TODO: Open extracted motion events and validate the actual frames. -DVR_SCAN_COMMAND: str = "python -m dvr_scan" +# Run the CLI with the interpreter running the tests, not whichever `python` is on PATH +# (they differ when pytest runs from a separate build venv). +DVR_SCAN_COMMAND: str = f'"{sys.executable}" -m dvr_scan' BASE_OUTPUT_NAME: str = "traffic_camera" # Should yield 3 events with all detector types. BASE_COMMAND = [ @@ -572,3 +580,98 @@ def test_scan_haze_maxarea(tmp_path): ) assert "No motion events detected in input." in output + + +# +# Option defaults, validation, and error reporting (2.0 release fixes) +# + + +def _parse_cli(args: ty.List[str], config_text: str = "") -> ScanSettings: + """Parse `args` in-process with a config containing `config_text`.""" + config = ConfigRegistry() + if config_text: + config.load_from_string(config_text) + return ScanSettings(args=get_cli_parser(config).parse_args(args), config=config) + + +def test_flags_do_not_override_config_when_absent(): + """Flags not given on the command line must not shadow their config file values.""" + settings = _parse_cli(["-i", "video.mp4"], "scan-only = yes\ndebug = yes\n") + assert settings.get("scan-only") is True + assert settings.get("debug") is True + settings = _parse_cli(["-i", "video.mp4"]) + assert settings.get("scan-only") is False + assert settings.get("debug") is False + assert _parse_cli(["-i", "video.mp4", "-so"]).get("scan-only") is True + + +def test_scan_only_from_config_file(tmp_path): + """`scan-only = yes` in a config file enables scan-only mode without -so.""" + cfg_path = tmp_path / "config.cfg" + cfg_path.write_text("scan-only = yes\n") + output_dir = tmp_path / "output" + output = _run_dvr_scan(BASE_COMMAND + ["--config", cfg_path, "--output-dir", output_dir]) + assert "Detected %d motion events in input." % (BASE_COMMAND_NUM_EVENTS) in output + assert not output_dir.exists() or not os.listdir(output_dir) + + +def test_thumbnails_option_validation(): + assert _parse_cli(["-i", "video.mp4", "--thumbnails", "HighScore"]).get("thumbnails") == ( + "highscore" + ) + assert _parse_cli(["-i", "video.mp4"]).get("thumbnails") is None + with pytest.raises(SystemExit): + _parse_cli(["-i", "video.mp4", "--thumbnails", "lowscore"]) + + +def test_report_unwritable_path_fails_before_scan(tmp_path): + """An unwritable --report path is rejected up front with a message, not a traceback.""" + not_a_dir = tmp_path / "file.txt" + not_a_dir.write_text("") + result = subprocess.run( + " ".join( + [DVR_SCAN_COMMAND] + + BASE_COMMAND + + ["--scan-only", "--report", '"%s"' % (not_a_dir / "report.csv")] + ), + shell=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + assert result.returncode == 1 + assert "cannot write report file" in result.stdout + assert "Traceback" not in result.stdout + assert "Detected" not in result.stdout, "Report path must be checked before scanning." + + +def test_report_check_does_not_leave_file_behind(tmp_path): + from dvr_scan.controller import _check_report_path + + report = tmp_path / "out" / "report.json" + _check_report_path(_parse_cli(["-i", "video.mp4", "--report", str(report)])) + assert report.parent.is_dir() + assert not report.exists() + + +@pytest.mark.parametrize("error", [OSError("disk full"), RuntimeError("ffmpeg exited with code 1")]) +def test_main_logs_io_errors_without_traceback(monkeypatch, caplog, error): + """OSError/RuntimeError (e.g. ffmpeg failing to write output) are reported as a + message only, unless running with debug verbosity.""" + import dvr_scan.__main__ as main_module + + settings = _parse_cli(["-i", "video.mp4", "--quiet"]) + + def _raise(_settings): + raise error + + monkeypatch.setattr(main_module, "parse_settings", lambda: settings) + monkeypatch.setattr(main_module, "run_dvr_scan", _raise) + with caplog.at_level(logging.DEBUG, logger="dvr_scan"): + with pytest.raises(SystemExit) as excinfo: + main_module.main() + assert excinfo.value.code == 1 + (record,) = [record for record in caplog.records if record.levelno == logging.CRITICAL] + assert str(error) in record.getMessage() + assert not record.exc_info diff --git a/tests/test_encode_mode.py b/tests/test_encode_mode.py index cf6c3430..f6234e5a 100644 --- a/tests/test_encode_mode.py +++ b/tests/test_encode_mode.py @@ -15,6 +15,7 @@ import shutil import subprocess +import sys import typing as ty from pathlib import Path @@ -25,7 +26,9 @@ pytestmark = pytest.mark.skipif(not is_ffmpeg_available(), reason="ffmpeg not available") -DVR_SCAN_COMMAND: str = "python -m dvr_scan" +# Run the CLI with the interpreter running the tests, not whichever `python` is on PATH +# (they differ when pytest runs from a separate build venv). +DVR_SCAN_COMMAND: str = f'"{sys.executable}" -m dvr_scan' # Matches BASE_COMMAND in test_cli.py aside from output mode; yields 3 events. BASE_COMMAND = [ @@ -228,3 +231,37 @@ def test_encode_mode_with_mask_output(tmp_path): for name in files: if name != "mask.avi": assert _count_frames(tmp_path / name) > 0 + + +def _probe_pix_fmt_and_size(path: Path) -> ty.Tuple[str, int, int]: + import av + + with av.open(str(path)) as container: + codec = container.streams.video[0].codec_context + return (codec.pix_fmt, codec.width, codec.height) + + +def test_encode_mode_yuv420p_with_odd_mask(tmp_path): + """Default ENCODE output must be 4:2:0 (not the High 4:4:4 profile ffmpeg picks for + the BGR pipe), including MP4 mask output, whose size is odd with -df 3 (1280 / 3).""" + _run_dvr_scan( + BASE_COMMAND + + [ + "--end-time", + "8s", + "--downscale-factor", + "3", + "--mask-output", + "mask.mp4", + "--output-dir", + tmp_path, + ] + ) + clips = sorted(tmp_path.glob("traffic_camera.DSME_*.mp4")) + assert clips + for path in clips: + assert _probe_pix_fmt_and_size(path) == ("yuv420p", 1280, 720) + pix_fmt, width, height = _probe_pix_fmt_and_size(tmp_path / "mask.mp4") + assert pix_fmt == "yuv420p" + assert width % 2 == 0 and height % 2 == 0 + assert _count_frames(tmp_path / "mask.mp4") > 0 diff --git a/tests/test_encoder.py b/tests/test_encoder.py index b4651276..828f96f4 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -30,6 +30,7 @@ MaskWriter, OpenCVEncoder, OutputMode, + _video_encode_args, build_pipe_command, get_encoder_type, ) @@ -163,6 +164,38 @@ def test_mask_writer_ffmpeg(tmp_path): assert _count_frames(mask_file) == 3 +def _probe_video(path: Path) -> ty.Tuple[str, int, int]: + """(pix_fmt, width, height) of the first video stream of `path`.""" + import av + + with av.open(str(path)) as container: + codec = container.streams.video[0].codec_context + return (codec.pix_fmt, codec.width, codec.height) + + +def _require_ffmpeg(): + from dvr_scan.platform import is_ffmpeg_available + + if not is_ffmpeg_available(): + pytest.skip("ffmpeg not available") + + +def test_mask_writer_ffmpeg_odd_size(tmp_path): + """Mask frames with odd dimensions (e.g. from -df 3) must encode as 4:2:0 MP4, padded + to even dimensions.""" + _require_ffmpeg() + writer = MaskWriter( + path=Path("mask.mp4"), + fourcc=FOURCC, + frame_rate=FRAME_RATE, + output_dir=tmp_path, + ) + for i in range(3): + writer.write_frame(_make_frame(size=(63, 47)), _tc(i / FRAME_RATE)) + writer.close() + assert _probe_video(tmp_path / "mask.mp4") == ("yuv420p", 64, 48) + + def test_get_encoder_type(): assert get_encoder_type(OutputMode.SCAN_ONLY) is None assert get_encoder_type(OutputMode.OPENCV) is OpenCVEncoder @@ -244,6 +277,101 @@ def test_build_pipe_command_multiple_audio_sources(): assert args.count("-ss") == 2 and args.count("-i") == 3 +def test_build_pipe_command_pix_fmt(): + """Output defaults to 4:2:0 (players often can't decode the 4:4:4 that ffmpeg picks + for the bgr24 pipe), but a user-specified pixel format must be kept.""" + args = build_pipe_command(Path("out.mp4"), (640, 480), 25.0, DEFAULT_ENCODE_ARGS) + assert args[args.index("-pix_fmt", args.index("pipe:0")) + 1] == "yuv420p" + # Even frame size and no user filters: no pad filter needed. + assert "-vf" not in args + args = build_pipe_command(Path("out.mp4"), (640, 480), 25.0, "-c:v libx264 -pix_fmt yuv444p") + output_args = args[args.index("pipe:0") :] + assert output_args.count("-pix_fmt") == 1 + assert output_args[output_args.index("-pix_fmt") + 1] == "yuv444p" + + +def test_build_pipe_command_odd_size_padded(): + """Odd frame sizes (e.g. -df 3 or region crops) must be padded to even dimensions, + appending to any user-specified video filter (which may itself change the size).""" + args = build_pipe_command(Path("out.mp4"), (427, 241), 25.0, DEFAULT_ENCODE_ARGS) + assert args[args.index("-vf") + 1] == "pad=ceil(iw/2)*2:ceil(ih/2)*2" + args = build_pipe_command(Path("out.mp4"), (640, 480), 25.0, "-c:v libx264 -vf hflip") + assert args.count("-vf") == 1 + assert args[args.index("-vf") + 1] == "hflip,pad=ceil(iw/2)*2:ceil(ih/2)*2" + + +def test_pipe_encoder_yuv420p_odd_size(tmp_path): + """ENCODE output must be yuv420p, and odd-sized frames must still encode.""" + _require_ffmpeg() + encoder = FFmpegPipeEncoder( + video_input=SimpleNamespace(sources=[], paths=[Path("odd.mp4")]), + frame_rate=FRAME_RATE, + output_dir=tmp_path, + comp_file=Path("events.mp4"), + ) + for i in range(5): + encoder.write_frame(_make_frame(size=(65, 49)), _tc(i / FRAME_RATE)) + encoder.finish_event(_event(1, 0.0, 0.5)) + encoder.close() + assert _probe_video(tmp_path / "events.mp4") == ("yuv420p", 66, 50) + assert _count_frames(tmp_path / "events.mp4") == 5 + + +def _beep_onset(path: Path) -> float: + """Time in seconds (from the start of the output) where the first audio stream of + `path` first becomes loud.""" + import av + + with av.open(str(path)) as container: + stream = container.streams.audio[0] + sample_rate = stream.sample_rate + frames = list(container.decode(stream)) + start = float(frames[0].pts * frames[0].time_base) + samples = np.concatenate([np.abs(frame.to_ndarray()).max(axis=0) for frame in frames]) + loud = np.nonzero(samples > 0.5 * samples.max())[0] + return start + loud[0] / sample_rate + + +def test_video_encode_args_filter_complex_skips_pad(): + """A filter graph cannot be combined with -vf, so no pad filter is added for it.""" + args = _video_encode_args("-c:v libx264 -filter_complex [0:v]hflip[v] -map [v]", (321, 241)) + assert "-vf" not in args + assert args[:2] == ["-pix_fmt", "yuv420p"] + + +def test_pipe_encoder_audio_container_start_offset(tmp_path): + """Audio must stay aligned for inputs with a nonzero container start time (e.g. DVR + .ts files). ffmpeg already offsets input seeks by the container start time, so it + must not be added again (it was, shifting audio by +2s here). + + audio_start_offset.mp4: 4s of 64x48 video at 10 fps with a 1 kHz tone from 1.5s to + 2.0s (otherwise silent), remuxed with -output_ts_offset 2 so the video starts at 2.0s + and the AAC audio (with its priming samples) at 1.936s. Generated with: + + ffmpeg -f lavfi -i testsrc2=s=64x48:r=10:d=4 -f lavfi -i sine=f=1000:sample_rate=16000:d=4 + -filter:a "volume='between(t,1.5,2.0)':eval=frame" -c:v libx264 -pix_fmt yuv420p + -g 10 -crf 30 -c:a aac -b:a 32k -ac 1 base.mp4 + ffmpeg -i base.mp4 -c copy -output_ts_offset 2 audio_start_offset.mp4 + """ + _require_ffmpeg() + from dvr_scan.video_input import open_input + + video = open_input([Path(__file__).parent / "resources" / "audio_start_offset.mp4"]) + assert video.sources[0].start_time is not None + assert abs(float(video.sources[0].start_time) - 1.936) < 0.01 + encoder = FFmpegPipeEncoder( + video_input=video, frame_rate=10.0, output_dir=tmp_path, comp_file=None + ) + # Event covering 1.0s - 2.5s of the input: the tone should start 0.5s into the clip. + event_start = 1.0 + for i in range(15): + encoder.write_frame(_make_frame(size=(64, 48)), _tc(event_start + i / 10.0)) + encoder.finish_event(_event(1, event_start, event_start + 1.5)) + encoder.close() + output = tmp_path / "audio_start_offset.DSME_0001.mp4" + assert abs(_beep_onset(output) - 0.5) < 0.05 + + def _make_source_info( path: Path, audio_duration_seconds: ty.Optional[float], time_base=Fraction(1, 48000) ) -> SourceInfo: @@ -326,6 +454,19 @@ def test_output_mode_reexported_from_scanner(): assert ScannerOutputMode is OutputMode +def _patch_ffmpeg_available(monkeypatch, available: bool): + """Patch the ffmpeg check in every module that imported it by name. init_scanner() + decides the fallback in dvr_scan.shared, but MotionScanner.set_output() and the + encoders re-check through their own imports, so patching only one module makes the + outcome depend on whether the machine running the tests has ffmpeg on PATH.""" + import dvr_scan.encoder as encoder_module + import dvr_scan.scanner as scanner_module + import dvr_scan.shared as shared + + for module in (shared, scanner_module, encoder_module): + monkeypatch.setattr(module, "is_ffmpeg_available", lambda: available) + + def test_init_scanner_default_output_mode(traffic_camera_video, monkeypatch): """The default output mode is ENCODE when ffmpeg is available, falling back to OPENCV with a warning when it is not (explicitly-set modes still raise instead).""" @@ -341,10 +482,10 @@ def make_settings() -> ScanSettings: config=ConfigRegistry(), ) - monkeypatch.setattr(shared, "is_ffmpeg_available", lambda: True) + _patch_ffmpeg_available(monkeypatch, True) assert shared.init_scanner(make_settings()).output_mode == OutputMode.ENCODE - monkeypatch.setattr(shared, "is_ffmpeg_available", lambda: False) + _patch_ffmpeg_available(monkeypatch, False) assert shared.init_scanner(make_settings()).output_mode == OutputMode.OPENCV @@ -377,10 +518,10 @@ def make_settings() -> ScanSettings: config=ConfigRegistry(), ) - monkeypatch.setattr(shared, "is_ffmpeg_available", lambda: True) + _patch_ffmpeg_available(monkeypatch, True) assert shared.init_scanner(make_settings())._mask_file == Path("mask.mp4") - monkeypatch.setattr(shared, "is_ffmpeg_available", lambda: False) + _patch_ffmpeg_available(monkeypatch, False) assert shared.init_scanner(make_settings())._mask_file == Path("mask.avi") diff --git a/tests/test_presets.py b/tests/test_presets.py index f36a3b32..bafb76a7 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -11,10 +11,19 @@ """DVR-Scan Preset Tests""" import logging +import os +import stat import pytest -from dvr_scan.config import USER_CONFIG_FILE_PATH, ConfigLoadFailure, ConfigRegistry +import dvr_scan.presets as presets_module +from dvr_scan.config import ( + USER_CONFIG_FILE_PATH, + ConfigLoadFailure, + ConfigRegistry, + atomic_write_text, + set_user_config_option, +) from dvr_scan.presets import ( BUILTIN_PRESETS, DEFAULT_PRESET_NAME, @@ -157,13 +166,64 @@ def test_timecode_list_format_choices(): def test_startup_mode_choices(): config = ConfigRegistry() - config.load_from_string("startup-mode = classic\n") - assert config.get("startup-mode") == "classic" - assert ConfigRegistry().get("startup-mode") == "wizard" + config.load_from_string("startup-mode = wizard\n") + assert config.get("startup-mode") == "wizard" + config = ConfigRegistry() + config.load_from_string("startup-mode = advanced\n") + assert config.get("startup-mode") == "advanced" + # Code default is Advanced Mode so existing installs keep the full settings view; the + # shipped config template sets wizard for fresh installs. + assert ConfigRegistry().get("startup-mode") == "advanced" with pytest.raises(ConfigLoadFailure): ConfigRegistry().load_from_string("startup-mode = invalid\n") +@pytest.mark.parametrize("value", ["classic", "CLASSIC", "Classic"]) +def test_startup_mode_legacy_classic_alias(value): + """`classic` (the pre-release name of Advanced Mode) is accepted and normalized.""" + config = ConfigRegistry() + config.load_from_string("startup-mode = %s\n" % value) + assert config.get("startup-mode") == "advanced" + + +def test_save_default_preserves_unmanaged_options(tmp_path, monkeypatch): + """Saving the Default preset rewrites the user config, but must keep options the UI + has no widget for (startup-mode, verbosity, ...). UI values still win.""" + user_config = tmp_path / "dvr-scan.cfg" + user_config.write_text( + "startup-mode = wizard\nverbosity = debug\nsave-log = no\nthreshold = 0.9\n" + ) + monkeypatch.setattr(presets_module, "USER_CONFIG_FILE_PATH", user_config) + path = save_user_preset(DEFAULT_PRESET_NAME, make_settings(threshold=0.3)) + assert path == user_config + config = ConfigRegistry() + config.load(user_config) + assert config.get("startup-mode") == "wizard" + assert config.get("verbosity") == "debug" + assert config.get("save-log") is False + assert config.get("threshold") == 0.3 + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_save_default_over_invalid_config_still_writes(tmp_path, monkeypatch): + user_config = tmp_path / "dvr-scan.cfg" + user_config.write_text("threshold = not-a-number\n") + monkeypatch.setattr(presets_module, "USER_CONFIG_FILE_PATH", user_config) + save_user_preset(DEFAULT_PRESET_NAME, make_settings(threshold=0.3)) + config = ConfigRegistry() + config.load(user_config) + assert config.get("threshold") == 0.3 + + +def test_save_default_creates_missing_config(tmp_path, monkeypatch): + user_config = tmp_path / "nested" / "dvr-scan.cfg" + monkeypatch.setattr(presets_module, "USER_CONFIG_FILE_PATH", user_config) + save_user_preset(DEFAULT_PRESET_NAME, make_settings(threshold=0.3)) + config = ConfigRegistry() + config.load(user_config) + assert config.get("threshold") == 0.3 + + def test_load_preset_logs_at_debug_level(tmp_path): """Preset loads should record file/string load messages at DEBUG, not INFO.""" save_user_preset("Custom", make_settings(threshold=0.25), base_dir=tmp_path) @@ -185,3 +245,129 @@ def test_scan_user_presets_warns_only_once_per_unchanged_file(tmp_path, caplog): caplog.clear() assert scan_user_presets(tmp_path) == [] assert len(caplog.records) == 0 + + +def test_save_default_does_not_resurrect_cleared_ui_options(tmp_path, monkeypatch): + """Options the UI manages come from the UI state only: an output folder cleared in + the UI ("Ask Me") must not be brought back from the old file.""" + user_config = tmp_path / "dvr-scan.cfg" + user_config.write_text("output-dir = C:/old\nthreshold = 0.9\nload-region = r.txt\n") + monkeypatch.setattr(presets_module, "USER_CONFIG_FILE_PATH", user_config) + save_user_preset(DEFAULT_PRESET_NAME, make_settings(threshold=0.3)) + text = user_config.read_text() + assert "output-dir" not in text + assert "load-region" not in text + assert "threshold = 0.3" in text + + +def test_save_default_preserves_validated_unknown_and_raw_values(tmp_path, monkeypatch): + user_config = tmp_path / "dvr-scan.cfg" + user_config.write_text( + "max-event-length = 30s\n" + "future-option = 5\n" + "timecode = yes\n" # migrated name: dropped, the UI writes time-code + "region-of-interest = 0 0 10 10\n" # deprecated: dropped + "verbosity = DEBUG\n" + ) + monkeypatch.setattr(presets_module, "USER_CONFIG_FILE_PATH", user_config) + save_user_preset(DEFAULT_PRESET_NAME, make_settings(threshold=0.3)) + text = user_config.read_text() + assert "max-event-length = 30s\n" in text + assert "future-option = 5\n" in text + assert "verbosity = DEBUG\n" in text + assert "timecode" not in text.replace("time-code", "") + assert "region-of-interest" not in text + config = ConfigRegistry() + config.load(user_config) + assert config.get("max-event-length") == "30s" + + +# +# Config file encoding and atomic writes +# + +# U+0441 CYRILLIC SMALL LETTER ES in UTF-8. The 0x81 byte is undefined in cp1252, so +# decoding this file in the Windows locale encoding fails. +CYRILLIC_UTF8 = b"\xd1\x81" + + +def test_preset_with_non_ascii_utf8_text_loads(tmp_path): + (tmp_path / "Camera.cfg").write_bytes(b"# Front door " + CYRILLIC_UTF8 + b"\nthreshold = 0.3\n") + (preset,) = scan_user_presets(tmp_path) + assert load_preset(preset).get("threshold") == 0.3 + + +def test_config_with_utf8_bom_loads_first_option(tmp_path): + path = tmp_path / "bom.cfg" + path.write_bytes(b"\xef\xbb\xbfthreshold = 0.42\r\nmin-event-length = 3\r\n") + config = ConfigRegistry() + config.load(path) + assert config.get("threshold") == 0.42 + assert config.get("min-event-length") == "3" + + +def test_config_falls_back_to_locale_encoding(tmp_path, monkeypatch): + monkeypatch.setattr("locale.getpreferredencoding", lambda do_setlocale=True: "cp1252") + path = tmp_path / "legacy.cfg" + path.write_bytes(b"output-dir = C:/Users/Jos\xe9\n") + config = ConfigRegistry() + config.load(path) + assert config.get("output-dir") == "C:/Users/Jos\N{LATIN SMALL LETTER E WITH ACUTE}" + + +def test_undecodable_config_is_a_load_failure(tmp_path, monkeypatch, caplog): + monkeypatch.setattr("locale.getpreferredencoding", lambda do_setlocale=True: "ascii") + path = tmp_path / "Broken.cfg" + path.write_bytes(b"threshold = 0.3\n# \x81\n") + with pytest.raises(ConfigLoadFailure): + ConfigRegistry().load(path) + # Presets that cannot be decoded are skipped with a warning instead of raising. + with caplog.at_level(logging.WARNING, logger="dvr_scan"): + assert scan_user_presets(tmp_path) == [] + assert any("Broken.cfg" in record.getMessage() for record in caplog.records) + + +def test_save_user_preset_writes_utf8(tmp_path): + settings = make_settings(output_dir="C:/Videos/" + CYRILLIC_UTF8.decode("utf-8")) + path = save_user_preset("Unicode", settings, base_dir=tmp_path) + assert CYRILLIC_UTF8 in path.read_bytes() + assert load_preset(scan_user_presets(tmp_path)[0]).get("output-dir") == ( + "C:/Videos/" + CYRILLIC_UTF8.decode("utf-8") + ) + + +@pytest.mark.parametrize("newline", [b"\n", b"\r\n"]) +def test_atomic_write_keeps_newline_style(tmp_path, newline): + path = tmp_path / "dvr-scan.cfg" + path.write_bytes(b"threshold = 0.5" + newline) + atomic_write_text(path, "threshold = 0.6\nmin-event-length = 3\n") + assert path.read_bytes() == b"threshold = 0.6" + newline + b"min-event-length = 3" + newline + + +def test_set_user_config_option_keeps_lf_file(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_bytes(b"threshold = 0.5\nstartup-mode = wizard\n") + set_user_config_option("startup-mode", "advanced", path) + assert path.read_bytes() == b"threshold = 0.5\nstartup-mode = advanced\n" + + +def test_atomic_write_read_only_file(tmp_path): + """Replacing a read-only file must raise the real error and leave no temp file.""" + path = tmp_path / "dvr-scan.cfg" + path.write_text("threshold = 0.5\n") + os.chmod(path, stat.S_IREAD) + try: + if os.name == "nt": + with pytest.raises(PermissionError) as excinfo: + atomic_write_text(path, "threshold = 0.6\n") + # The error is from replacing the file (tmp -> path), not the cleanup. + assert excinfo.value.filename2 == str(path) + assert path.read_text() == "threshold = 0.5\n" + else: + # POSIX allows replacing a read-only file; its mode bits are kept. + atomic_write_text(path, "threshold = 0.6\n") + assert path.read_text() == "threshold = 0.6\n" + assert stat.S_IMODE(path.stat().st_mode) == stat.S_IREAD + assert list(tmp_path.glob("*.tmp")) == [] + finally: + os.chmod(path, stat.S_IREAD | stat.S_IWRITE) diff --git a/tests/test_scan_context.py b/tests/test_scan_context.py index 06838985..ca4a7dce 100644 --- a/tests/test_scan_context.py +++ b/tests/test_scan_context.py @@ -16,10 +16,16 @@ import logging import platform import re +import threading import typing as ty +from pathlib import Path import pytest +from scenedetect import FrameTimecode +import dvr_scan.scanner as scanner_module +from dvr_scan.encoder import EventContext, EventEncoder, OutputMode +from dvr_scan.platform import is_ffmpeg_available from dvr_scan.region import Point from dvr_scan.scanner import DetectorType, MotionScanner from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2 @@ -641,3 +647,166 @@ def test_scan_logs_detection_parameters(traffic_camera_video, caplog: pytest.Log assert "Detection parameters: threshold = 0.25," in caplog.text assert "downscale_factor = 2, frame_skip = 1" in caplog.text + + +class _RecordingEncoder(EventEncoder): + """Encoder recording the timecodes of the frames it receives for each event.""" + + STREAMS_FRAMES = True + + def __init__(self, fail_on_close: bool = False): + self.events: ty.List[ty.Tuple[FrameTimecode, FrameTimecode, ty.List[FrameTimecode]]] = [] + self.orphan_frames: ty.List[FrameTimecode] = [] + self.closed = False + self._fail_on_close = fail_on_close + + def write_frame(self, frame_bgr, timecode: FrameTimecode): + self.orphan_frames.append(timecode) + + def finish_event(self, context: EventContext): + self.events.append((context.start, context.end, self.orphan_frames)) + self.orphan_frames = [] + + def close(self): + self.closed = True + if self._fail_on_close: + raise RuntimeError("close failed") + + +def _scan_recording_frames( + scanner: MotionScanner, monkeypatch +) -> ty.Tuple[_RecordingEncoder, ty.List[FrameTimecode]]: + """Scan with a recording encoder. Returns it and the timecodes of all processed frames.""" + processed: ty.List[FrameTimecode] = [] + + decode_event = scanner_module.DecodeEvent + + def recording_decode_event(*args, **kwargs): + event = decode_event(*args, **kwargs) + processed.append(event.timecode) + return event + + monkeypatch.setattr(scanner_module, "DecodeEvent", recording_decode_event) + scanner.set_output(output_mode=OutputMode.OPENCV) + encoder = _RecordingEncoder() + monkeypatch.setattr(scanner, "_create_encoder", lambda: encoder) + scanner.scan() + return encoder, processed + + +def _check_event_frames(encoder: _RecordingEncoder, processed: ty.List[FrameTimecode]): + """Each clip must contain exactly the processed frames within its event's [start, end).""" + assert not encoder.orphan_frames + for start, end, frames in encoder.events: + assert end.seconds >= start.seconds + expected = [tc for tc in processed if start <= tc < end] + assert [tc.seconds for tc in frames] == [tc.seconds for tc in expected], ( + f"frames sent for event {start}-{end} don't match its span" + ) + + +@pytest.mark.parametrize("frame_skip", [0, 2]) +def test_event_frames_match_span(traffic_camera_video, monkeypatch, frame_skip): + """Frames sent to the encoder must match each event's span, including the frame on + which the merge window ends the event when it starts before the event end.""" + scanner = MotionScanner([traffic_camera_video], frame_skip=frame_skip) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) + scanner.set_event_params(min_event_len=4, time_pre_event=0) + encoder, processed = _scan_recording_frames(scanner, monkeypatch) + assert len(encoder.events) == 3 + _check_event_frames(encoder, processed) + + +@pytest.mark.parametrize("frame_skip", [0, 1]) +def test_max_event_length(traffic_camera_video, monkeypatch, frame_skip): + """-Y/--max-event-length must split events at the limit in seconds regardless of + frame skip, without post-event padding, so clips match the reported spans and + no event ends before it starts.""" + scanner = MotionScanner([traffic_camera_video], frame_skip=frame_skip) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) + scanner.set_event_params(min_event_len=4, time_pre_event=0, max_event_len="3s") + encoder, processed = _scan_recording_frames(scanner, monkeypatch) + events = [(start, end) for start, end, _ in encoder.events] + # The first two of the 3 baseline events (5.6s and 5.3s long) are split once each. + assert len(events) == 5 + for start, end in events: + assert 0 < end.seconds - start.seconds <= 3.0 + 1e-6 + # Motion continues past the split in the second event, so the next event starts + # exactly where the previous one ended. + assert events[3][0] == events[2][1] + assert abs(events[2][1].seconds - events[2][0].seconds - 3.0) < 1e-6 + _check_event_frames(encoder, processed) + + +@pytest.mark.parametrize("frame_skip", [0, 1]) +def test_max_event_length_bounds_pre_roll(traffic_camera_video, monkeypatch, frame_skip): + """A pre-event window (-tb) at least as long as -Y must not make the first event of a + motion group longer than the limit; the pre-roll is trimmed to fit instead.""" + scanner = MotionScanner([traffic_camera_video], frame_skip=frame_skip) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) + scanner.set_event_params(min_event_len=4, time_pre_event="3s", max_event_len="1s") + encoder, processed = _scan_recording_frames(scanner, monkeypatch) + events = [(start, end) for start, end, _ in encoder.events] + assert len(events) > 3 + for start, end in events: + assert 0 < end.seconds - start.seconds <= 1.0 + 1e-6 + _check_event_frames(encoder, processed) + + +def _failing(method, fail_after: int): + calls = [0] + + def wrapper(*args, **kwargs): + calls[0] += 1 + if calls[0] > fail_after: + raise RuntimeError("injected failure") + return method(*args, **kwargs) + + return wrapper + + +@pytest.mark.skipif(not is_ffmpeg_available(), reason="ffmpeg not available") +@pytest.mark.parametrize("failure", ["main", "decode"]) +def test_scan_failure_stops_threads(traffic_camera_video, tmp_path, monkeypatch, failure): + """An exception in the main loop or the decode thread must stop and join both worker + threads and finalize ffmpeg (unlocking the output file) before it is re-raised.""" + threads_before = set(threading.enumerate()) + scanner = MotionScanner([traffic_camera_video]) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) + scanner.set_event_params(min_event_len=4, time_pre_event=0) + scanner.set_output(output_dir=tmp_path, output_mode=OutputMode.ENCODE) + # Fail partway through the first event (frames ~9-149). + if failure == "main": + monkeypatch.setattr( + scanner_module.MotionDetector, + "update", + _failing(scanner_module.MotionDetector.update, 60), + ) + else: + monkeypatch.setattr(scanner._input, "read", _failing(scanner._input.read, 60)) + with pytest.raises(RuntimeError, match="injected failure"): + scanner.scan() + assert set(threading.enumerate()) <= threads_before + assert scanner._encoder._process is None + # The partial clip must be finalized and not held open. + clips = list(tmp_path.glob("*.mp4")) + assert len(clips) == 1 + clips[0].rename(tmp_path / "renamed.mp4") + + +def test_encoder_close_failure_raised(traffic_camera_video, tmp_path, monkeypatch): + """An exception closing the encoder must be raised from scan(), and must not prevent + the mask writer from being closed.""" + scanner = MotionScanner([traffic_camera_video]) + scanner.set_regions(regions=[TRAFFIC_CAMERA_ROI]) + scanner.set_video_time(end_time="2s") + scanner.set_output( + output_dir=tmp_path, mask_file=Path("mask.avi"), output_mode=OutputMode.OPENCV + ) + encoder = _RecordingEncoder(fail_on_close=True) + monkeypatch.setattr(scanner, "_create_encoder", lambda: encoder) + with pytest.raises(RuntimeError, match="close failed"): + scanner.scan() + assert encoder.closed + assert scanner._mask_writer._writer is None + assert (tmp_path / "mask.avi").exists() diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 00000000..883b95e3 --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,306 @@ +# +# DVR-Scan: Video Motion Event Detection & Extraction Tool +# -------------------------------------------------------------- +# [ Site: https://www.dvr-scan.com/ ] +# [ Repo: https://github.com/Breakthrough/DVR-Scan ] +# +# Copyright (C) 2026 Brandon Castellano . +# DVR-Scan is licensed under the BSD 2-Clause License; see the included +# LICENSE file, or visit one of the above pages for details. +# +"""Tests for application startup mode selection (wizard vs. Advanced Mode) and the config +file line editor behind it. All Tk-free.""" + +import pytest + +from dvr_scan.app.__main__ import get_cli_parser +from dvr_scan.app.startup import ( + ADVANCED, + CLASSIC, + WIZARD, + has_prior_logs, + normalize_startup_mode, + resolve_default_startup_mode, + save_startup_mode, +) +from dvr_scan.config import ConfigLoadFailure, ConfigRegistry, set_user_config_option + +TEMPLATE = """# DVR-Scan Config File +# Some comment that must survive. + +#startup-mode = WIZARD + +# Detection threshold. +threshold = 0.5 +""" + + +def _config(contents: str = "") -> ConfigRegistry: + config = ConfigRegistry() + if contents: + config.load_from_string(contents) + return config + + +# +# set_user_config_option +# + + +def test_set_option_replaces_commented_line(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text(TEMPLATE) + assert set_user_config_option("startup-mode", "advanced", path) == path + text = path.read_text() + assert "startup-mode = advanced\n" in text + assert "#startup-mode" not in text + # Everything else is untouched. + assert "# Some comment that must survive.\n" in text + assert "# Detection threshold.\nthreshold = 0.5\n" in text + assert text.count("startup-mode") == 1 + + +def test_set_option_replaces_active_line(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("startup-mode = advanced\nthreshold = 0.5\n") + set_user_config_option("startup-mode", "wizard", path) + assert path.read_text() == "startup-mode = wizard\nthreshold = 0.5\n" + + +def test_set_option_prefers_active_over_commented_line(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("#startup-mode = WIZARD\nstartup-mode = advanced\n") + set_user_config_option("startup-mode", "wizard", path) + assert path.read_text() == "#startup-mode = WIZARD\nstartup-mode = wizard\n" + + +def test_set_option_appends_when_absent(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("threshold = 0.5\n") + set_user_config_option("startup-mode", "wizard", path) + assert path.read_text() == "threshold = 0.5\n\nstartup-mode = wizard\n" + + +def test_set_option_creates_missing_file_with_header(tmp_path): + path = tmp_path / "nested" / "dvr-scan.cfg" + set_user_config_option("startup-mode", "wizard", path) + lines = path.read_text().splitlines() + assert lines[0].startswith("# DVR-Scan Config File") + assert lines[1].startswith("# Created by: DVR-Scan") + assert lines[-1] == "startup-mode = wizard" + assert list(path.parent.glob("*.tmp")) == [] + + +def test_set_option_result_loads_cleanly(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text(TEMPLATE) + set_user_config_option("startup-mode", "advanced", path) + config = ConfigRegistry() + config.load(path) + assert config.get("startup-mode") == "advanced" + assert config.get("threshold") == 0.5 + + +def test_set_option_does_not_match_similar_option_names(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("max-log-size = 1\n") + set_user_config_option("max-log-files", "3", path) + assert path.read_text() == "max-log-size = 1\n\nmax-log-files = 3\n" + + +# +# save_startup_mode +# + + +def test_save_startup_mode_lowercases_and_validates(tmp_path): + path = tmp_path / "dvr-scan.cfg" + save_startup_mode("Wizard", path) + assert "startup-mode = wizard" in path.read_text() + with pytest.raises(ValueError): + save_startup_mode("sideways", path) + + +def test_save_startup_mode_writes_legacy_classic_as_advanced(tmp_path): + path = tmp_path / "dvr-scan.cfg" + save_startup_mode("Classic", path) + assert "startup-mode = advanced" in path.read_text() + config = ConfigRegistry() + config.load(path) + assert config.get("startup-mode") == ADVANCED + + +@pytest.mark.parametrize( + "value,expected", + [ + ("wizard", WIZARD), + (" Wizard ", WIZARD), + ("advanced", ADVANCED), + ("ADVANCED", ADVANCED), + ("classic", ADVANCED), + ("Classic", ADVANCED), + ("sideways", "sideways"), + ], +) +def test_normalize_startup_mode(value, expected): + assert normalize_startup_mode(value) == expected + + +def test_classic_constant_is_advanced(): + assert CLASSIC == ADVANCED == "advanced" + + +# +# has_prior_logs +# + + +def test_has_prior_logs_missing_or_empty_dir(tmp_path): + assert has_prior_logs(tmp_path / "missing") is False + assert has_prior_logs(tmp_path) is False + + +def test_has_prior_logs_ignores_current_log_file(tmp_path): + current = tmp_path / "dvr-scan-app.abc123.log" + current.write_text("") + assert has_prior_logs(tmp_path, exclude=current) is False + assert has_prior_logs(tmp_path) is True + + +@pytest.mark.parametrize( + "name", ["dvr-scan-app.log", "dvr-scan.log", "dvr-scan-app.x1y2z3.log", "dvr-scan.q.log"] +) +def test_has_prior_logs_matches_current_and_legacy_names(tmp_path, name): + current = tmp_path / "dvr-scan-app.current.log" + current.write_text("") + (tmp_path / name).write_text("") + assert has_prior_logs(tmp_path, exclude=current) is True + + +def test_has_prior_logs_ignores_unrelated_files(tmp_path): + (tmp_path / "other.log").write_text("") + (tmp_path / "dvr-scan.txt").write_text("") + assert has_prior_logs(tmp_path) is False + + +# +# resolve_default_startup_mode +# + + +@pytest.mark.parametrize("value", ["wizard", "WIZARD", "Wizard"]) +def test_resolve_explicit_config_value_wins(value): + config = _config(f"startup-mode = {value}\n") + for user_config_exists in (True, False): + for prior_logs in (True, False): + assert resolve_default_startup_mode( + config, user_config_exists, prior_logs, allow_heuristic=True + ) == (WIZARD, False) + + +def test_resolve_existing_config_without_option_is_advanced(): + assert resolve_default_startup_mode( + _config(), user_config_exists=True, prior_logs=False, allow_heuristic=True + ) == (CLASSIC, False) + + +def test_resolve_heuristic_new_user_is_wizard_and_persists(): + assert resolve_default_startup_mode( + _config(), user_config_exists=False, prior_logs=False, allow_heuristic=True + ) == (WIZARD, True) + + +def test_resolve_heuristic_existing_user_is_advanced_and_persists(): + assert resolve_default_startup_mode( + _config(), user_config_exists=False, prior_logs=True, allow_heuristic=True + ) == (CLASSIC, True) + + +@pytest.mark.parametrize("value", ["advanced", "Advanced", "classic", "CLASSIC"]) +def test_resolve_explicit_advanced_or_legacy_classic(value): + config = _config(f"startup-mode = {value}\n") + assert resolve_default_startup_mode( + config, user_config_exists=True, prior_logs=False, allow_heuristic=True + ) == (ADVANCED, False) + + +def test_resolve_heuristic_disabled_falls_back_to_default(): + assert resolve_default_startup_mode( + _config(), user_config_exists=False, prior_logs=False, allow_heuristic=False + ) == (CLASSIC, False) + + +# +# --startup-mode CLI option +# + + +@pytest.mark.parametrize( + "value,expected", + [("wizard", WIZARD), ("Advanced", ADVANCED), ("advanced", ADVANCED), ("Classic", ADVANCED)], +) +def test_cli_startup_mode_option(value, expected): + # The legacy name "classic" is still accepted, as an alias of "advanced". + args = get_cli_parser().parse_args(["--startup-mode", value]) + assert args.startup_mode == expected + + +def test_cli_startup_mode_absent_by_default(): + assert not hasattr(get_cli_parser().parse_args([]), "startup_mode") + + +def test_cli_startup_mode_rejects_invalid(): + with pytest.raises(SystemExit): + get_cli_parser().parse_args(["--startup-mode", "sideways"]) + + +# +# set_user_config_option: review follow-ups +# + + +def test_set_option_matches_key_case_insensitively(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("STARTUP-MODE = WIZARD\nthreshold = 0.5\n") + set_user_config_option("startup-mode", "advanced", path) + assert path.read_text() == "startup-mode = advanced\nthreshold = 0.5\n" + config = ConfigRegistry() + config.load(path) + assert config.get("startup-mode") == "advanced" + + +def test_set_option_replaces_multiline_value(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_text("startup-mode =\n wizard\n\tmore\nthreshold = 0.5\n") + set_user_config_option("startup-mode", "advanced", path) + assert path.read_text() == "startup-mode = advanced\nthreshold = 0.5\n" + + +def test_set_option_keeps_utf8_bom_file_as_utf8(tmp_path): + path = tmp_path / "dvr-scan.cfg" + path.write_bytes(b"\xef\xbb\xbfstartup-mode = wizard\noutput-dir = C:/caf\xc3\xa9\n") + set_user_config_option("startup-mode", "advanced", path) + raw = path.read_bytes() + assert raw.startswith(b"\xef\xbb\xbf") + assert raw.count(b"startup-mode") == 1 + # Text mode writes platform newlines (CRLF on Windows); compare on normalized text. + text = raw.decode("utf-8-sig").replace("\r\n", "\n") + assert "startup-mode = advanced\n" in text + assert "output-dir = C:/caf\u00e9\n" in text + + +def test_set_option_round_trips_locale_encoded_text(tmp_path): + """Read and write use the locale encoding like ConfigRegistry.load, so non-ASCII + values written by the app survive a startup-mode toggle.""" + path = tmp_path / "dvr-scan.cfg" + path.write_text("output-dir = C:/Users/Jos\u00e9/Videos\n") + set_user_config_option("startup-mode", "advanced", path) + assert path.read_text() == "output-dir = C:/Users/Jos\u00e9/Videos\n\nstartup-mode = advanced\n" + config = ConfigRegistry() + config.load(path) + assert config.get("output-dir") == "C:/Users/Jos\u00e9/Videos" + + +def test_duplicate_option_is_a_config_load_failure(): + with pytest.raises(ConfigLoadFailure): + ConfigRegistry().load_from_string("threshold = 0.5\nthreshold = 0.6\n") diff --git a/tests/test_subtractor.py b/tests/test_subtractor.py new file mode 100644 index 00000000..68d1e691 --- /dev/null +++ b/tests/test_subtractor.py @@ -0,0 +1,51 @@ +# +# DVR-Scan: Video Motion Event Detection & Extraction Tool +# -------------------------------------------------------------- +# [ Site: https://www.dvr-scan.com/ ] +# [ Repo: https://github.com/Breakthrough/DVR-Scan ] +# +# Copyright (C) 2016 Brandon Castellano . +# DVR-Scan is licensed under the BSD 2-Clause License; see the included +# LICENSE file, or visit one of the above pages for details. +# +"""Tests for background subtractor availability reporting.""" + +import importlib.metadata + +import pytest + +from dvr_scan import subtractor + + +def _installed(names): + def version(name): + if name in names: + return "5.0.0.93" + raise importlib.metadata.PackageNotFoundError(name) + + return version + + +@pytest.mark.parametrize( + "installed,expected", + [ + (set(), "pip install opencv-contrib-python`"), + ({"opencv-contrib-python"}, "--force-reinstall --no-deps opencv-contrib-python`"), + ( + {"opencv-contrib-python-headless"}, + "--force-reinstall --no-deps opencv-contrib-python-headless`", + ), + ( + {"opencv-contrib-python", "opencv-contrib-python-headless"}, + "--force-reinstall --no-deps opencv-contrib-python`", + ), + ], +) +def test_cnt_unavailable_message(monkeypatch, installed, expected): + """The hint names the contrib package that is installed, and says to reinstall it + (the regular OpenCV package overwrites the shared cv2 directory), or to install it + when no contrib build is present.""" + monkeypatch.setattr(importlib.metadata, "version", _installed(installed)) + message = subtractor.cnt_unavailable_message() + assert message.endswith(expected), message + assert "CNT" in message diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py index c886388a..fd7c89e6 100644 --- a/tests/test_thumbnails.py +++ b/tests/test_thumbnails.py @@ -8,8 +8,13 @@ # DVR-Scan is licensed under the BSD 2-Clause License; see the included # LICENSE file, or visit one of the above pages for details. # -"""Tests for the thumbnail imaging helpers. These cover the pure (Tk-free) functions -only; the background loader and Tk wiring are verified manually in the GUI.""" +"""Tests for the thumbnail imaging helpers and the background loader's lifecycle (with +Tk-free fakes); the Tk wiring itself is verified manually in the GUI.""" + +import shutil +import threading +import time +import weakref import numpy as np @@ -17,6 +22,7 @@ FILMSTRIP_GAP, PLACEHOLDER_COLOR, THUMBNAIL_HEIGHT, + ThumbnailLoader, filmstrip_image, frame_to_image, placeholder_image, @@ -78,3 +84,179 @@ def test_filmstrip_image_concatenates_frames(): def test_filmstrip_image_empty_falls_back_to_placeholder(): image = filmstrip_image([], height=54) assert image.size == placeholder_image(height=54).size + + +def test_pad_right_adds_transparent_margin(): + import PIL.Image + + from dvr_scan.app.thumbnails import pad_right + + image = PIL.Image.new("RGB", (10, 4), PLACEHOLDER_COLOR) + padded = pad_right(image, 8) + assert padded.size == (18, 4) + assert padded.mode == "RGBA" + # Original pixels kept, margin fully transparent. + assert padded.getpixel((0, 0))[:3] == PLACEHOLDER_COLOR + assert padded.getpixel((17, 0))[3] == 0 + # Zero margin is a no-op (same object, no mode change). + assert pad_right(image, 0) is image + + +# --- ThumbnailLoader lifecycle (Tk-free fakes) --------------------------------------------- + + +class _FakeRoot: + """Records `after` / `after_cancel` calls instead of scheduling anything.""" + + def __init__(self): + self.scheduled = [] + self.cancelled = [] + + def after(self, _ms, func): + self.scheduled.append(func) + return f"after#{len(self.scheduled)}" + + def after_cancel(self, after_id): + self.cancelled.append(after_id) + + +class _FakeWidget: + def __init__(self, root: _FakeRoot): + self._fake_root = root + self.exists = True + + def nametowidget(self, name): + assert name == "." + return self._fake_root + + def winfo_exists(self): + return self.exists + + +def _wait_for(event: threading.Event): + assert event.wait(timeout=10.0), "timed out waiting for the loader worker" + + +def test_loader_polls_on_root_and_cancel_cancels_pending_poll(): + """The poll loop is scheduled on the root (not the row widget, which may be destroyed + first) and cancelled with the loader, so no callback fires on a destroyed widget.""" + root = _FakeRoot() + loader = ThumbnailLoader(_FakeWidget(root)) + loader.submit("row-0", lambda: None) + assert len(root.scheduled) == 1 + loader.cancel() + assert root.cancelled == ["after#1"] + # A late poll after cancellation is a no-op and does not reschedule. + root.scheduled[0]() + assert len(root.scheduled) == 1 + + +def test_loader_drain_stops_when_widget_destroyed(): + root = _FakeRoot() + widget = _FakeWidget(root) + loader = ThumbnailLoader(widget) + loader.submit("row-0", lambda: None) + widget.exists = False + root.scheduled[0]() + assert len(root.scheduled) == 1 + loader.cancel() + + +def test_loader_finish_runs_on_exit_after_last_job(): + exited = threading.Event() + exit_threads = [] + done = [] + + def on_exit(): + exit_threads.append(threading.current_thread()) + exited.set() + + loader = ThumbnailLoader(_FakeWidget(_FakeRoot()), on_exit=on_exit) + loader.submit("row-0", lambda: done.append(0)) + loader.submit("row-1", lambda: done.append(1)) + loader.finish() + _wait_for(exited) + assert done == [0, 1] + # Runs on the worker thread (which then exits), not the caller's. + assert exit_threads[0] is not threading.current_thread() + loader._thread.join(timeout=10.0) + assert not loader._thread.is_alive() + # Submitting after finish() is ignored. + loader.submit("row-2", lambda: done.append(2)) + assert loader._pending == 2 + + +def test_loader_cancel_runs_on_exit_and_skips_remaining_jobs(): + started = threading.Event() + release = threading.Event() + exited = threading.Event() + done = [] + + def slow(): + started.set() + _wait_for(release) + done.append("slow") + + loader = ThumbnailLoader(_FakeWidget(_FakeRoot()), on_exit=exited.set) + loader.submit("row-0", slow) + loader.submit("row-1", lambda: done.append("skipped")) + _wait_for(started) + loader.cancel() + release.set() + _wait_for(exited) + assert done == ["slow"] + + +def test_loader_without_jobs_runs_on_exit_immediately(): + for stop in ("finish", "cancel"): + exits = [] + loader = ThumbnailLoader(_FakeWidget(_FakeRoot()), on_exit=lambda e=exits: e.append(1)) + getattr(loader, stop)() + # Idempotent: on_exit runs once. + loader.finish() + loader.cancel() + assert exits == [1] + + +class _Provider: + def __call__(self): + return None + + +def test_idle_worker_does_not_keep_last_provider_alive(): + """An idle worker blocked on the next job must not hold the last provider (which may + keep a video open) via stale locals.""" + loader = ThumbnailLoader(_FakeWidget(_FakeRoot())) + provider = _Provider() + ref = weakref.ref(provider) + loader.submit("row-0", provider) + del provider + # Wait until the result is posted (the worker then blocks waiting for more jobs). + assert loader._results.get(timeout=10.0) == ("row-0", None) + deadline = time.monotonic() + 10.0 + while ref() is not None and time.monotonic() < deadline: + time.sleep(0.01) + assert ref() is None + loader.cancel() + + +# --- Event table thumbnails release the source video -------------------------------------- + + +def test_event_frame_providers_release_closes_video(tmp_path, traffic_camera_video): + """Once released, the shared input is closed: on Windows an open file can't be + renamed, which is how a user noticed the leak.""" + from scenedetect import FrameTimecode + + from dvr_scan.app.scan_window import _event_frame_providers + from dvr_scan.scanner import MotionEvent + + source = tmp_path / "source.mp4" + shutil.copyfile(traffic_camera_video, source) + event = MotionEvent(start=FrameTimecode(10, 25.0), end=FrameTimecode(40, 25.0)) + providers, release = _event_frame_providers([str(source)], [event]) + frames = providers[0]() + assert frames + del frames + release() + source.rename(tmp_path / "renamed.mp4") diff --git a/tests/test_video_list.py b/tests/test_video_list.py index 8dbaf0cd..e204ea70 100644 --- a/tests/test_video_list.py +++ b/tests/test_video_list.py @@ -553,3 +553,47 @@ def test_refresh_button_state_all_rows_selected(): "down": "disabled", "sort": "normal", } + + +# --- Add button and first video resolution ------------------------------------------------ + + +def test_add_button_uses_on_add_callback_when_given(monkeypatch): + """The Scan Wizard routes "+" through its own browse handler (parented dialog, locked + navigation) instead of an unparented dialog opened by the list.""" + area = VideoList.__new__(VideoList) + calls = [] + area._on_add = lambda: calls.append("wizard-browse") + monkeypatch.setattr( + "dvr_scan.app.video_list.tkinter.filedialog.askopenfilename", + lambda **kwargs: pytest.fail("list must not open its own dialog"), + ) + area._on_add_clicked() + assert calls == ["wizard-browse"] + + +def test_add_button_without_callback_parents_dialog_to_toplevel(monkeypatch): + area = VideoList.__new__(VideoList) + area._on_add = None + area._root = SimpleNamespace(winfo_toplevel=lambda: "list-toplevel") + seen = {} + + def fake_askopenfilename(**kwargs): + seen.update(kwargs) + return () + + monkeypatch.setattr( + "dvr_scan.app.video_list.tkinter.filedialog.askopenfilename", fake_askopenfilename + ) + area._on_add_clicked() + assert seen["parent"] == "list-toplevel" + + +def test_first_video_resolution(): + area = _list_with({"a": _row("a.mp4", "/tmp/a.mp4"), "b": _row("b.mp4", "/tmp/b.mp4")}, []) + assert area.first_video_resolution is None + area._order = ["b", "a"] + area._rows["b"].info.resolution = "1280 x 720" + assert area.first_video_resolution == (1280, 720) + area._rows["b"].info.resolution = "N/A" + assert area.first_video_resolution is None diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 9b6787a5..993cbe89 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -13,6 +13,8 @@ from types import SimpleNamespace +import pytest + from dvr_scan.app.application import InputArea from dvr_scan.app.wizard import ScanWizard @@ -407,3 +409,425 @@ def test_wizard_remove_selected_removes_then_refreshes_state_and_breaks(): assert wizard._on_remove_selected(object()) == "break" assert wizard.calls == ["remove_selected", "state_changed"] + + +# --- Preset step initial selection -------------------------------------------------------- + + +class _FakeCombo: + """Stand-in for the preset ttk.Combobox: tracks the selection and item config.""" + + def __init__(self): + self.value = "" + self.config = {} + + def get(self): + return self.value + + def set(self, value): + self.value = value + + def __setitem__(self, key, value): + self.config[key] = value + + +def _preset_step_for_on_show(active_preset): + from dvr_scan.app.wizard import _PresetStep + + step = _PresetStep.__new__(_PresetStep) + step._active_preset = active_preset + step._combo_initialized = False + step._presets = {} + step._combo = _FakeCombo() + step._preset_desc = {"text": ""} + return step + + +def test_preset_step_shows_default_on_first_show(): + step = _preset_step_for_on_show("Default") + step.on_show() + assert step._combo.get() == "Default" + assert "Default" in step._combo.config["values"] + assert step._preset_desc["text"].startswith("Balanced") + + +def test_preset_step_shows_custom_when_no_active_preset(): + step = _preset_step_for_on_show(None) + step.on_show() + assert step._combo.get() == "" + assert step._active_preset is None + assert step._preset_desc["text"].startswith("Custom settings") + + +def test_preset_step_on_show_keeps_user_selection_after_first_show(): + step = _preset_step_for_on_show("Default") + step.on_show() + step._combo.set("Fast Scan") + step._active_preset = "Fast Scan" + step.on_show() + assert step._combo.get() == "Fast Scan" + + +# --- Output step without ffmpeg (B4) ------------------------------------------------------ + + +class _FakeModeCombo: + """Stand-in for the output mode ttk.Combobox.""" + + def __init__(self, values): + self.values = list(values) + self.index = 0 + + def get(self): + return self.values[self.index] + + def current(self, index): + self.index = index + + def selection_clear(self): + pass + + +def _output_step(ffmpeg_available: bool, mode_index: int = 0, directory: str = "/out"): + from dvr_scan.app.wizard import _OutputStep + + step = _OutputStep.__new__(_OutputStep) + step._ffmpeg_available = ffmpeg_available + step._modes = _OutputStep.mode_choices(ffmpeg_available) + step._mode_combo = _FakeModeCombo(label for label, _ in step._modes) + step._mode_combo.current(mode_index) + step._directory = directory + step._user_selected_dir = bool(directory) + step._select_button = {} + step._directory_label = SimpleNamespace(set=lambda _value: None) + return step + + +def test_output_mode_choices_label_ffmpeg_modes_when_missing(): + from dvr_scan.app.wizard import FFMPEG_MISSING_SUFFIX, FFMPEG_OUTPUT_MODES, _OutputStep + from dvr_scan.scanner import OutputMode + + assert _OutputStep.mode_choices(True) == _OutputStep._MODES + choices = _OutputStep.mode_choices(False) + assert [mode for _, mode in choices] == [mode for _, mode in _OutputStep._MODES] + for label, mode in choices: + assert label.endswith(FFMPEG_MISSING_SUFFIX) == (mode in FFMPEG_OUTPUT_MODES) + assert "Recommended" not in label or mode not in FFMPEG_OUTPUT_MODES + assert dict(choices)[next(lbl for lbl, m in choices if m == OutputMode.OPENCV)] == ( + OutputMode.OPENCV + ) + + +def test_output_step_rejects_ffmpeg_modes_without_ffmpeg(): + from dvr_scan.app.wizard import FFMPEG_OUTPUT_MODES + from dvr_scan.scanner import OutputMode + + for index in range(5): + step = _output_step(ffmpeg_available=False, mode_index=index) + error = step.validate() + if step.output_mode in FFMPEG_OUTPUT_MODES: + assert error is not None and "ffmpeg" in error + else: + assert error is None + # With ffmpeg, the default (MP4) mode is fine. + step = _output_step(ffmpeg_available=True, mode_index=0) + assert step.output_mode == OutputMode.ENCODE + assert step.validate() is None + + +def test_output_step_sync_falls_back_to_opencv_without_ffmpeg(): + from dvr_scan.config import ConfigRegistry + from dvr_scan.scanner import OutputMode + from dvr_scan.shared import ScanSettings + + for config_text in ("", "output-mode = ffmpeg\n"): + config = ConfigRegistry() + if config_text: + config.load_from_string(config_text) + step = _output_step(ffmpeg_available=False, mode_index=4) + step.sync_from_settings(ScanSettings(args=None, config=config)) + assert step.output_mode == OutputMode.OPENCV + assert step.validate() is None + + +# --- Dialog parents (S3e) ----------------------------------------------------------------- + + +def test_output_step_select_dialog_is_parented(monkeypatch): + step = _output_step(ffmpeg_available=True, directory="") + step.frame = SimpleNamespace(winfo_toplevel=lambda: "wizard-window") + seen = {} + + def fake_askdirectory(**kwargs): + seen.update(kwargs) + return "/chosen" + + monkeypatch.setattr("dvr_scan.app.wizard.tkinter.filedialog.askdirectory", fake_askdirectory) + step._on_select() + assert seen["parent"] == "wizard-window" + assert step.directory == "/chosen" and step.user_selected_directory + + +def test_preset_load_failure_dialog_is_parented(monkeypatch): + import dvr_scan.presets as presets + from dvr_scan.app.wizard import _PresetStep + from dvr_scan.config import ConfigLoadFailure, ConfigRegistry + + step = _PresetStep.__new__(_PresetStep) + step.frame = SimpleNamespace(winfo_toplevel=lambda: "wizard-window") + step.config = ConfigRegistry() + step._presets = {p.name: p for p in presets.list_presets()} + step._active_preset = presets.DEFAULT_PRESET_NAME + selected = ["Fast Scan"] + step._combo = SimpleNamespace( + selection_clear=lambda: None, + get=lambda: selected[0], + set=lambda value: selected.__setitem__(0, value), + ) + step._threshold = SimpleNamespace(get=lambda: str(step.config.get("threshold"))) + + def failing_load(_preset): + raise ConfigLoadFailure(init_log=[]) + + shown = [] + monkeypatch.setattr(presets, "load_preset", failing_load) + monkeypatch.setattr( + "dvr_scan.app.wizard.tkinter.messagebox.showerror", lambda **kwargs: shown.append(kwargs) + ) + step._on_selected() + assert shown[0]["parent"] == "wizard-window" + assert selected[0] == presets.DEFAULT_PRESET_NAME + + +# --- Dropping a config file on the wizard (S3c) ------------------------------------------- + + +def _videos_step_for_drop(on_load_config): + from dvr_scan.app.wizard import _VideosStep + + step = _VideosStep.__new__(_VideosStep) + step._window = "wizard-window" + step._can_accept_drop = None + step._on_load_config = on_load_config + step._set_drag_active = lambda _active: None + step._refresh = lambda: None + step._drop_zone = SimpleNamespace(tk=SimpleNamespace(splitlist=lambda data: data)) + added = [] + step.input_area = SimpleNamespace(add_video=lambda path, parent: added.append(path)) + return step, added + + +def test_wizard_drop_loads_config_files_and_adds_videos(tmp_path): + video = tmp_path / "a.mp4" + first_cfg = tmp_path / "one.CFG" + second_cfg = tmp_path / "two.cfg" + for path in (video, first_cfg, second_cfg): + path.write_text("") + loaded = [] + step, added = _videos_step_for_drop(lambda path: loaded.append(path) or True) + step._on_drop(SimpleNamespace(data=[str(first_cfg), str(video), str(second_cfg)])) + # Only the first config file is loaded, and none are added as videos. + assert loaded == [str(first_cfg)] + assert added == [str(video)] + + +def test_wizard_drop_tries_next_config_if_first_fails(tmp_path): + bad_cfg, good_cfg = tmp_path / "bad.cfg", tmp_path / "good.cfg" + for path in (bad_cfg, good_cfg): + path.write_text("") + loaded = [] + step, added = _videos_step_for_drop( + lambda path: loaded.append(path) or path.endswith("good.cfg") + ) + step._on_drop(SimpleNamespace(data=[str(bad_cfg), str(good_cfg)])) + assert loaded == [str(bad_cfg), str(good_cfg)] + assert added == [] + + +# --- Output folder only copied to Advanced Mode if chosen by the user (S3h) --------------- + + +def _wizard_for_apply(directory: str, user_selected: bool): + from dvr_scan.scanner import OutputMode + + wizard = ScanWizard.__new__(ScanWizard) + wizard._preset_step = SimpleNamespace(threshold=0.15) + wizard._output_step = SimpleNamespace( + scan_only=False, + output_mode=OutputMode.OPENCV, + directory=directory, + user_selected_directory=user_selected, + ) + motion = SimpleNamespace(_threshold=SimpleNamespace(set=lambda _value: None)) + output = SimpleNamespace(_output_dir="", _on_mode_combo_selected=lambda: None) + scan = SimpleNamespace(scan_only=None) + return wizard, motion, output, scan + + +def test_apply_to_classic_skips_auto_filled_output_dir(): + wizard, motion, output, scan = _wizard_for_apply("/videos", user_selected=False) + wizard.apply_to_classic(motion, output, scan) + assert output._output_dir == "" + + +def test_apply_to_classic_copies_user_selected_output_dir(): + wizard, motion, output, scan = _wizard_for_apply("/chosen", user_selected=True) + wizard.apply_to_classic(motion, output, scan) + assert output._output_dir == "/chosen" + + +# --- Advanced Mode start/end time and regions in wizard scans (S3f) ----------------------- + + +def test_wizard_scan_overrides_use_first_video_and_resolution(): + wizard = ScanWizard.__new__(ScanWizard) + calls = [] + wizard._get_scan_overrides = lambda video, size: calls.append((video, size)) or {"x": 1} + wizard._videos_step = SimpleNamespace( + input_area=SimpleNamespace(videos=["a.mp4", "b.mp4"], first_video_resolution=(64, 48)) + ) + assert wizard._scan_overrides() == {"x": 1} + assert calls == [("a.mp4", (64, 48))] + # No videos, or no Advanced Mode window: nothing to apply. + wizard._videos_step.input_area.videos = [] + assert wizard._scan_overrides() == {} + wizard._get_scan_overrides = None + wizard._videos_step.input_area.videos = ["a.mp4"] + assert wizard._scan_overrides() == {} + + +def test_summary_lists_advanced_mode_overrides(): + from dvr_scan.app.wizard import _SummaryStep + + step = _SummaryStep.__new__(_SummaryStep) + step._get_overrides = lambda: { + "start-time": "00:00:02.000", + "end-time": "00:00:05.000", + "regions": [[1], [2]], + } + assert step._override_lines() == [ + "", + "From Advanced Mode:", + " Start Time: 00:00:02.000", + " End Time: 00:00:05.000", + " Regions: 2", + ] + step._get_overrides = lambda: {} + assert step._override_lines() == [] + step._get_overrides = None + assert step._override_lines() == [] + + +# --- Start Scan errors are shown (B4) ----------------------------------------------------- + + +def _wizard_for_start_scan(overrides=None): + from dvr_scan.config import ConfigRegistry + + wizard = ScanWizard.__new__(ScanWizard) + wizard._settings = SimpleNamespace(args=None) + wizard._preset_step = SimpleNamespace(config=ConfigRegistry(), threshold=0.15) + + def update(settings): + settings.set("input", ["a.mp4"]) + return settings + + wizard._videos_step = SimpleNamespace( + input_area=SimpleNamespace(update=update, videos=["a.mp4"], first_video_resolution=None) + ) + wizard._output_step = SimpleNamespace(scan_only=True, output_mode=None, directory="") + wizard._get_scan_overrides = (lambda _video, _size: overrides) if overrides else None + wizard._scan_step = SimpleNamespace(body="scan-body") + wizard._window = "wizard-window" + wizard._scan_view = None + wizard._scanning = False + return wizard + + +def test_wizard_start_scan_shows_error_when_setup_fails(monkeypatch): + import pytest + from scenedetect import VideoOpenFailure + + import dvr_scan.app.wizard as wizard_module + + for error in (VideoOpenFailure("mismatched"), ValueError("no ffmpeg"), OSError("bad dir")): + wizard = _wizard_for_start_scan() + + def failing_view(*_args, error=error): + raise error + + shown = [] + monkeypatch.setattr(wizard_module, "ScanProgressView", failing_view) + monkeypatch.setattr( + wizard_module.tkinter.messagebox, + "showerror", + lambda shown=shown, **kwargs: shown.append(kwargs), + ) + wizard._start_scan() + assert len(shown) == 1, error + assert shown[0]["parent"] == "wizard-window" + assert str(error) in shown[0]["message"] + assert wizard._scanning is False and wizard._scan_view is None + + # Anything else is a bug: it propagates to the release-mode error handler. + def broken_view(*_args): + raise RuntimeError("bug") + + monkeypatch.setattr(wizard_module, "ScanProgressView", broken_view) + with pytest.raises(RuntimeError): + _wizard_for_start_scan()._start_scan() + + +def test_wizard_start_scan_applies_advanced_mode_overrides(monkeypatch): + import dvr_scan.app.wizard as wizard_module + + seen = {} + + def capture_view(_parent, settings, *_args): + seen.update(settings.app_settings) + raise ValueError("stop here") + + monkeypatch.setattr(wizard_module, "ScanProgressView", capture_view) + monkeypatch.setattr(wizard_module.tkinter.messagebox, "showerror", lambda **kwargs: None) + _wizard_for_start_scan({"start-time": "00:00:02.000", "regions": [["r"]]})._start_scan() + assert seen["start-time"] == "00:00:02.000" + assert seen["regions"] == [["r"]] + assert seen["input"] == ["a.mp4"] + + +def test_preset_step_sync_replaces_config_with_loaded_config(): + """A config file dropped on the wizard replaces its base config, so options without + an Advanced Mode control (only copied when already set) still take effect.""" + from dvr_scan.app.wizard import _PresetStep + from dvr_scan.config import ConfigRegistry + from dvr_scan.shared import ScanSettings + + step = _preset_step_for_on_show("Default") + step._combo.set("Default") + step.config = ConfigRegistry() + threshold = ["0.15"] + step._threshold = SimpleNamespace(set=lambda value: threshold.__setitem__(0, value)) + loaded = ConfigRegistry() + loaded.load_from_string("min-event-length = 5s\nthreshold = 0.4\n") + settings = ScanSettings(args=None, config=loaded) + settings.set("threshold", 0.4) + _PresetStep.sync_from_settings(step, settings, active_preset=None, config=loaded) + assert step.config is not loaded + assert not step.config.is_default("min-event-length") + assert step.config.get("threshold") == 0.4 + assert threshold == ["0.4"] + assert step._active_preset is None + + +@pytest.mark.parametrize("cnt_available", [True, False]) +def test_fast_scan_preset_hidden_without_cnt(monkeypatch, cnt_available): + """The Fast Scan preset uses CNT, which pip installs can lack (the regular OpenCV + package overwrites the contrib build), so it is only offered when CNT works.""" + from dvr_scan.app import wizard as wizard_module + + monkeypatch.setattr( + wizard_module.SubtractorCNT, "is_available", staticmethod(lambda: cnt_available) + ) + names = [preset.name for preset in wizard_module._available_presets()] + assert ("Fast Scan" in names) == cnt_available + assert "High Sensitivity" in names and "Low Sensitivity" in names