Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/screenshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
156 changes: 156 additions & 0 deletions .github/workflows/test-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.'

Expand All @@ -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()
Expand Down
13 changes: 9 additions & 4 deletions .github/workflows/update-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ on:
workflow_dispatch:

env:
ffmpeg_version: "8.0"
UV_SYSTEM_PYTHON: 1


Expand Down Expand Up @@ -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
2 changes: 0 additions & 2 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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**:

<img alt="main app window" src="https://www.dvr-scan.com/assets/app-main-window.jpg" width="480"/>
<img alt="scan wizard" src="https://www.dvr-scan.com/assets/app-wizard-videos.jpg" width="480"/>

See the [User Guide](https://www.dvr-scan.com/guide/) for a more comprehensive overview.

Expand Down
10 changes: 10 additions & 0 deletions dist/build_headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading