diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
index 3fbe4fbe..220b8a11 100644
--- a/.github/workflows/build-windows.yml
+++ b/.github/workflows/build-windows.yml
@@ -103,7 +103,7 @@ jobs:
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
- name: PySceneDetect-win64_portable
+ name: PySceneDetect-win64
path: dist/scenedetect
include-hidden-files: true
@@ -117,7 +117,7 @@ jobs:
- uses: actions/download-artifact@v7
with:
- name: PySceneDetect-win64_portable
+ name: PySceneDetect-win64
path: build
- name: Test
diff --git a/README.md b/README.md
index 29ecff0d..2a0a42f1 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Video Cut Detection and Analysis Tool
----------------------------------------------------------
-### Latest Release: v0.6.7 (August 24, 2025)
+### Latest Release: v0.7 (May 3, 2026)
**Website**: [scenedetect.com](https://www.scenedetect.com)
diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md
index c1addc03..c2f31146 100644
--- a/RELEASE-PLAN.md
+++ b/RELEASE-PLAN.md
@@ -43,9 +43,11 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version
- [ ] Final commit on `releases/X.Y`: "Release vX.Y[.Z]".
- [ ] Tag `vX.Y[.Z]-release` on that commit and push. Wait for all tests/builds to pass.
-- [ ] Approve code signing request on SignPath, download signed artifacts
-- [ ] Prepare Windows portable .zip distribution with signed .EXE artifact
-- [ ] Draft release on Github using the tagged commit: include full changelog & release notes, portable .ZIP, .MSI installer, Python .whl/.tar.gz packages, and checksum manifests
+- [ ] Approve code signing request on SignPath, download `scenedetect-signed.zip`
+- [ ] Finalize Windows artifacts locally (CI can't do this - signing happens after the AppVeyor build, so the post-signing steps must run locally):
+ - Create `dist/signed/` and drop `scenedetect-signed.zip` (from SignPath) into it. No other inputs needed - the portable .zip is rebuilt from the signed .msi via `msiexec /a`, eliminating the AppVeyor download.
+ - Run `python scripts/finalize_windows_dist.py`. This extracts the signed `.msi` from the bundle, runs `msiexec /a` to recover the installed file tree, repacks it as the portable `.zip` with 7-Zip, writes `PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`, and then runs `scripts/validate_release.py` to verify filenames, hashes, Authenticode signatures, MSI/zip parity, and frozen `.exe` smoke tests.
+- [ ] Draft release on Github using the tagged commit: include full changelog & release notes, signed portable .ZIP, signed .MSI installer, Python .whl/.tar.gz packages, and checksum manifests (`PySceneDetect-X.Y.Z-win64.manifest.json` + `SHA256SUMS`)
- [ ] Verify all artifacts uploaded to Github release are valid and named correctly
- [ ] Smoke-test all release artifacts
diff --git a/appveyor.yml b/appveyor.yml
index 689716e4..e10f47ba 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -3,10 +3,14 @@
build: false
cache:
- - 'ffmpeg-%ffmpeg_version%-full_build.7z -> appveyor.yml'
- - 'packaging\windows\installer\advinst.msi -> appveyor.yml'
+ # FFmpeg self-invalidates via %ffmpeg_version% in the filename; AdvInst MSI and
+ # Inkscape rarely need refresh and have `if not exist` install guards, so we
+ # don't tie them to appveyor.yml (any edit there would force a cold-cache
+ # reinstall of all three and blow past the 10-minute build limit).
+ - 'ffmpeg-%ffmpeg_version%-full_build.7z'
+ - 'packaging\windows\installer\advinst.msi'
- '%LOCALAPPDATA%\uv\cache -> pyproject.toml'
- - 'C:\Program Files\Inkscape -> appveyor.yml'
+ - 'C:\Program Files\Inkscape'
# Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release
branches:
@@ -133,14 +137,14 @@ test_script:
- scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s
artifacts:
- # Portable ZIP (named PySceneDetect-X.Y.Z-portable.zip by stage_windows_dist.py)
- - path: dist/PySceneDetect-*-portable.zip
- name: PySceneDetect-win64_portable
+ # Portable ZIP (named PySceneDetect-X.Y.Z-win64.zip by stage_windows_dist.py)
+ - path: dist/PySceneDetect-*-win64.zip
+ name: PySceneDetect-win64
# MSI Installer + .EXE Bundle for Signing
- path: dist/scenedetect-signed.zip
name: PySceneDetect-win64_installer
# Build provenance: post-sync .aip and the portable payload manifest.
- path: dist/PySceneDetect.aip
name: PySceneDetect-build-manifest-aip
- - path: dist/PySceneDetect-*-portable.manifest.txt
+ - path: dist/PySceneDetect-*.manifest.txt
name: PySceneDetect-build-manifest-payload
diff --git a/docs/LATEST_VERSION b/docs/LATEST_VERSION
index 2228cad4..eb49d7c7 100644
--- a/docs/LATEST_VERSION
+++ b/docs/LATEST_VERSION
@@ -1 +1 @@
-0.6.7
+0.7
diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip
index f245c562..607630a9 100644
--- a/packaging/windows/installer/PySceneDetect.aip
+++ b/packaging/windows/installer/PySceneDetect.aip
@@ -70,67 +70,93 @@
+
-
-
-
-
-
-
-
+
+
-
+
+
+
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
+
+
-
+
@@ -154,45 +180,45 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -258,7 +284,7 @@
-
+
@@ -285,20 +311,43 @@
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -308,98 +357,126 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
-
-
-
-
+
+
+
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
-
-
+
-
-
-
-
+
+
+
-
+
+
@@ -413,7 +490,6 @@
-
@@ -430,43 +506,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -477,7 +519,6 @@
-
@@ -498,96 +539,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -597,16 +550,11 @@
-
-
-
-
-
@@ -622,59 +570,26 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1281,7 +1196,6 @@
-
@@ -1510,7 +1424,6 @@
-
@@ -1607,6 +1520,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1766,59 +1887,16 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1826,27 +1904,16 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -1854,10 +1921,8 @@
-
-
@@ -1889,6 +1954,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py
index f1b9b84f..e1b20d0f 100644
--- a/scenedetect/__init__.py
+++ b/scenedetect/__init__.py
@@ -75,7 +75,7 @@
# Used for module identification and when printing version & about info
# (e.g. calling `scenedetect version` or `scenedetect about`).
-__version__ = "0.7-dev1"
+__version__ = "0.7"
init_logger()
logger = getLogger("pyscenedetect")
diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py
index bd4754ab..f97bc320 100755
--- a/scenedetect/__main__.py
+++ b/scenedetect/__main__.py
@@ -17,7 +17,7 @@
from scenedetect._cli import scenedetect
from scenedetect._cli.context import CliContext
from scenedetect._cli.controller import run_scenedetect
-from scenedetect.platform import FakeTqdmLoggingRedirect, logging_redirect_tqdm
+from scenedetect.platform import DEBUG_MODE, FakeTqdmLoggingRedirect, logging_redirect_tqdm
def main():
@@ -46,14 +46,14 @@ def main():
run_scenedetect(context)
except KeyboardInterrupt:
logger.info("Stopped.")
- if __debug__:
+ if DEBUG_MODE:
raise
+ raise SystemExit(1) from None
except BaseException as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
- else:
- logger.critical("ERROR: Unhandled exception:", exc_info=ex)
- raise SystemExit(1) from None
+ logger.critical("ERROR: Unhandled exception:", exc_info=ex)
+ raise SystemExit(1) from ex
if __name__ == "__main__":
diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py
index a224d010..6c593865 100644
--- a/scenedetect/_cli/config.py
+++ b/scenedetect/_cli/config.py
@@ -30,6 +30,7 @@
from scenedetect.detector import FlashFilter
from scenedetect.detectors import ContentDetector
from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS
+from scenedetect.platform import DEBUG_MODE
from scenedetect.scene_manager import Interpolation
PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"]
@@ -763,7 +764,7 @@ def _load_from_disk(self, path=None):
config_file_contents = config_file.read()
config.read_string(config_file_contents, source=path)
except (ConfigParserError, OSError) as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
raise ConfigLoadFailure(self._init_log, reason=ex) from None
# At this point the config file syntax is correct, but we need to still validate
diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py
index fea0df7e..e5cebb0f 100644
--- a/scenedetect/_cli/context.py
+++ b/scenedetect/_cli/context.py
@@ -34,7 +34,7 @@
ThresholdDetector,
)
from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available
-from scenedetect.platform import init_logger
+from scenedetect.platform import DEBUG_MODE, init_logger
from scenedetect.scene_manager import SceneManager
from scenedetect.stats_manager import StatsManager
from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream
@@ -351,7 +351,7 @@ def get_detect_content_params(
try:
weights = ContentDetector.Components(*weights)
except ValueError as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
logger.debug(str(ex))
raise click.BadParameter(str(ex), param_hint="weights") from None
@@ -383,7 +383,7 @@ def get_detect_adaptive_params(
try:
weights = ContentDetector.Components(*weights)
except ValueError as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
logger.debug(str(ex))
raise click.BadParameter(str(ex), param_hint="weights") from None
@@ -543,7 +543,7 @@ def _open_video_stream(
Duration: {duration_str}""")
except FrameRateUnavailable as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
raise click.BadParameter(
"Failed to obtain frame rate for input video. Manually specify frame rate with the"
@@ -551,7 +551,7 @@ def _open_video_stream(
param_hint="-i/--input",
) from ex
except VideoOpenFailure as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
raise click.BadParameter(
"Failed to open input video{}: {}".format(
@@ -560,7 +560,7 @@ def _open_video_stream(
param_hint="-i/--input",
) from ex
except OSError as ex:
- if __debug__:
+ if DEBUG_MODE:
raise
raise click.BadParameter(
f"Input error:\n\n\t{ex!s}\n", param_hint="-i/--input"
diff --git a/scenedetect/platform.py b/scenedetect/platform.py
index 085d1d88..48c905bd 100644
--- a/scenedetect/platform.py
+++ b/scenedetect/platform.py
@@ -32,6 +32,20 @@
"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike`
(e.g. :class:`pathlib.Path`)."""
+DEBUG_MODE: bool = os.environ.get("SCENEDETECT_DEBUG", "").strip().lower() not in (
+ "",
+ "0",
+ "false",
+ "no",
+ "off",
+)
+"""True when the `SCENEDETECT_DEBUG` environment variable is set to a truthy value
+(`1`, `true`, `yes`, `on`, etc.); False when unset or set to `0`/`false`/`no`/`off`/empty.
+Use this to gate behavior intended only for development - e.g. re-raising unhandled
+exceptions for debuggers/pytest instead of logging gracefully and exiting. Default-off so
+end users on any install path (pip, pipx, the Windows .exe) get clean error output; pytest
+opts in via `tests/conftest.py`."""
+
##
## tqdm Library
##
@@ -302,70 +316,89 @@ def get_mkvmerge_version() -> str | None:
return output.splitlines()[0]
+def _query_package_version(dist_name: str, fallback_module: str | None) -> str | None:
+ """Return version of an installed package, querying PyPI metadata first then
+ falling back to the module's `__version__` attribute when metadata is missing.
+
+ PyInstaller bundles ship modules but not the `.dist-info` directories that
+ `importlib.metadata` reads, so the fallback is required for frozen builds.
+ Returns None when the package isn't installed.
+ """
+ try:
+ return importlib.metadata.version(dist_name)
+ except importlib.metadata.PackageNotFoundError:
+ pass
+ if fallback_module is None:
+ return None
+ try:
+ module = importlib.import_module(fallback_module)
+ except ModuleNotFoundError:
+ return None
+ return getattr(module, "__version__", None)
+
+
def get_system_version_info() -> str:
"""Get the system's operating system, Python, packages, and external tool versions.
Useful for debugging or filing bug reports.
Used for the `scenedetect version -a` command.
"""
- output_template = "{:<16} {}"
line_separator = "-" * 60
not_found_str = "Not Installed"
out_lines = []
- # System (Python, OS)
- output_template = "{:<16} {}"
- out_lines += ["System Info", line_separator]
- out_lines += [
- output_template.format(name, version)
- for name, version in (
- ("OS", f"{platform.platform()}"),
- ("Python", f"{platform.python_implementation()} {platform.python_version()}"),
- ("Architecture", " + ".join(platform.architecture())),
- )
- ]
+ system_info = (
+ ("OS", f"{platform.platform()}"),
+ ("Python", f"{platform.python_implementation()} {platform.python_version()}"),
+ ("Architecture", " + ".join(platform.architecture())),
+ )
- # Third-Party Packages: queried via PyPI distribution names. `cv2` is exposed by either
- # `opencv-python` or `opencv-python-headless` - both are listed so whichever is installed
- # gets reported. `scenedetect` is read from the package attribute since it must report a
- # version even when run uninstalled (e.g. from a source checkout). The import is deferred
- # to avoid a circular import at module load time.
+ # Third-Party Packages: queried via PyPI distribution names with a module-attribute
+ # fallback. PyInstaller bundles ship the modules but not the `.dist-info` metadata
+ # directories, so `importlib.metadata.version()` alone reports "Not Installed" for
+ # every package in a frozen build; reading `module.__version__` recovers the version
+ # there. `scenedetect` is read from the package attribute since it must report a
+ # version even when run uninstalled (e.g. from a source checkout). The import is
+ # deferred to avoid a circular import at module load time.
from scenedetect import __version__ as scenedetect_version
- out_lines += ["", "Packages", line_separator]
- out_lines.append(output_template.format("scenedetect", scenedetect_version))
- third_party_distributions = (
- "av",
- "click",
- "opencv-python",
- "opencv-python-headless",
- "imageio",
- "imageio-ffmpeg",
- "moviepy",
- "numpy",
- "platformdirs",
- "tqdm",
+ # (dist_name, fallback_module_name). Module fallback is only used when metadata is
+ # missing, so the metadata path still distinguishes `opencv-python` vs
+ # `opencv-python-headless` in source installs. In the frozen Windows build only
+ # `opencv-python-headless` is shipped, so `cv2` is attributed to that row alone.
+ third_party_packages = (
+ ("av", "av"),
+ ("click", "click"),
+ ("opencv-python", None),
+ ("opencv-python-headless", "cv2"),
+ ("imageio", "imageio"),
+ ("imageio-ffmpeg", "imageio_ffmpeg"),
+ ("moviepy", "moviepy"),
+ ("numpy", "numpy"),
+ ("platformdirs", "platformdirs"),
+ ("tqdm", "tqdm"),
)
- for dist_name in third_party_distributions:
- try:
- out_lines.append(
- output_template.format(dist_name, importlib.metadata.version(dist_name))
- )
- except importlib.metadata.PackageNotFoundError:
- out_lines.append(output_template.format(dist_name, not_found_str))
-
- # External Tools
- out_lines += ["", "Tools", line_separator]
+ package_versions = [("scenedetect", scenedetect_version)] + [
+ (dist_name, _query_package_version(dist_name, fallback_module) or not_found_str)
+ for dist_name, fallback_module in third_party_packages
+ ]
- tool_version_info = (
- ("ffmpeg", get_ffmpeg_version()),
- ("mkvmerge", get_mkvmerge_version()),
+ tool_versions = (
+ ("ffmpeg", get_ffmpeg_version() or not_found_str),
+ ("mkvmerge", get_mkvmerge_version() or not_found_str),
)
- for tool_name, tool_version in tool_version_info:
- out_lines.append(
- output_template.format(tool_name, tool_version if tool_version else not_found_str)
- )
+ # Size the label column to the longest label across every section so all three tables
+ # align consistently - `opencv-python-headless` exceeds the previous fixed width of 16.
+ label_width = max(len(name) for name, _ in (*system_info, *package_versions, *tool_versions))
+ output_template = f"{{:<{label_width}}} {{}}"
+
+ out_lines += ["System Info", line_separator]
+ out_lines += [output_template.format(name, value) for name, value in system_info]
+ out_lines += ["", "Packages", line_separator]
+ out_lines += [output_template.format(name, value) for name, value in package_versions]
+ out_lines += ["", "Tools", line_separator]
+ out_lines += [output_template.format(name, value) for name, value in tool_versions]
return "\n".join(out_lines)
diff --git a/scripts/_release_common.py b/scripts/_release_common.py
new file mode 100644
index 00000000..ef373070
--- /dev/null
+++ b/scripts/_release_common.py
@@ -0,0 +1,109 @@
+#
+# PySceneDetect: Python-Based Video Scene Detector
+# -------------------------------------------------------------------
+# [ Site: https://scenedetect.com ]
+# [ Docs: https://scenedetect.com/docs/ ]
+# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
+#
+# Copyright (C) 2026 Brandon Castellano .
+# PySceneDetect is licensed under the BSD 3-Clause License; see the
+# included LICENSE file, or visit one of the above pages for details.
+#
+"""Shared helpers for Windows release-finalization and validation scripts."""
+
+import hashlib
+import re
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+CHUNK = 1 << 20 # 1 MiB
+
+
+def msi_version(raw: str) -> str:
+ # Mirror scripts/update_installer.py - artifact filenames use the
+ # normalized X.Y.Z form, not the raw Python __version__.
+ parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
+ while len(parts) < 3:
+ parts.append("0")
+ return ".".join(parts[:4])
+
+
+def find_7zip() -> Path:
+ for candidate in (
+ Path(r"C:\Program Files\7-Zip\7z.exe"),
+ Path(r"C:\Program Files (x86)\7-Zip\7z.exe"),
+ ):
+ if candidate.exists():
+ return candidate
+ on_path = shutil.which("7z") or shutil.which("7z.exe")
+ if on_path:
+ return Path(on_path)
+ sys.exit("7-Zip not found. Install from https://www.7-zip.org/.")
+
+
+def sha256_file(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ for block in iter(lambda: f.read(CHUNK), b""):
+ h.update(block)
+ return h.hexdigest()
+
+
+def hash_zip_contents(zip_path: Path) -> list[dict]:
+ entries = []
+ with zipfile.ZipFile(zip_path) as zf:
+ for info in sorted(zf.infolist(), key=lambda i: i.filename):
+ if info.is_dir():
+ continue
+ h = hashlib.sha256()
+ with zf.open(info) as f:
+ for block in iter(lambda: f.read(CHUNK), b""):
+ h.update(block)
+ entries.append(
+ {
+ "path": info.filename,
+ "size": info.file_size,
+ "sha256": h.hexdigest(),
+ }
+ )
+ return entries
+
+
+def verify_authenticode(path: Path) -> None:
+ """Bail unless `path` carries a Valid Authenticode signature.
+
+ Catches the wrong-artifact case: e.g. someone drops the AppVeyor
+ pre-signing bundle into dist/signed/ instead of the SignPath output.
+ PowerShell's Get-AuthenticodeSignature works on both .exe and .msi.
+ """
+ if sys.platform != "win32":
+ print(f" (skipping Authenticode check for {path.name} on non-Windows)")
+ return
+ ps_cmd = (
+ f"$sig = Get-AuthenticodeSignature -FilePath '{path}'; "
+ "Write-Output $sig.Status; "
+ "if ($sig.SignerCertificate) { Write-Output $sig.SignerCertificate.Subject }"
+ )
+ result = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", ps_cmd],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
+ if result.returncode != 0 or not lines:
+ sys.exit(
+ f"Authenticode check for {path.name} failed to run.\n stderr: {result.stderr.strip()}"
+ )
+ status = lines[0]
+ subject = lines[1] if len(lines) > 1 else ""
+ print(f" Authenticode: {status} ({subject})")
+ if status != "Valid":
+ sys.exit(
+ f"Authenticode check FAILED for {path.name}: status={status!r}. "
+ "Verify scenedetect-signed.zip is the SignPath output, not an "
+ "unsigned AppVeyor artifact."
+ )
diff --git a/scripts/finalize_windows_dist.py b/scripts/finalize_windows_dist.py
new file mode 100644
index 00000000..6df2cb1f
--- /dev/null
+++ b/scripts/finalize_windows_dist.py
@@ -0,0 +1,228 @@
+#
+# PySceneDetect: Python-Based Video Scene Detector
+# -------------------------------------------------------------------
+# [ Site: https://scenedetect.com ]
+# [ Docs: https://scenedetect.com/docs/ ]
+# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
+#
+# Copyright (C) 2026 Brandon Castellano .
+# PySceneDetect is licensed under the BSD 3-Clause License; see the
+# included LICENSE file, or visit one of the above pages for details.
+#
+"""Finalize signed Windows release artifacts.
+
+Takes the signed bundle returned by SignPath, extracts the file tree from the
+signed MSI via `msiexec /a`, repacks it as the portable .zip with 7-Zip, and
+emits SHA256 manifests over the final release artifacts.
+
+Run after the SignPath signing job completes and `scenedetect-signed.zip`
+has been downloaded.
+
+Expected input (in --staging-dir, default `dist/signed/`):
+ scenedetect-signed.zip - SignPath bundle (signed .exe + .msi)
+
+Outputs (written to the same directory):
+ PySceneDetect-X.Y.Z-win64.zip - portable .zip rebuilt from the signed MSI
+ PySceneDetect-X.Y.Z-win64.msi - signed MSI extracted from the bundle
+ PySceneDetect-X.Y.Z-win64.manifest.json - structured per-file SHA256 manifest
+ SHA256SUMS - flat sha256sum -c compatible output
+"""
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+import tempfile
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+
+REPO_DIR = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_DIR))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import validate_release # noqa: E402
+from _release_common import ( # noqa: E402
+ find_7zip,
+ hash_zip_contents,
+ msi_version,
+ sha256_file,
+ verify_authenticode,
+)
+
+import scenedetect # noqa: E402
+
+VERSION = msi_version(scenedetect.__version__)
+
+
+def extract_signed_bundle(signed_zip: Path, dest: Path) -> tuple[Path, Path]:
+ print(f"Extracting {signed_zip.name}...")
+ with zipfile.ZipFile(signed_zip) as zf:
+ zf.extractall(dest)
+ exe = next((p for p in dest.rglob("scenedetect.exe")), None)
+ msi = next((p for p in dest.rglob("PySceneDetect-*.msi")), None)
+ if exe is None:
+ sys.exit(f"scenedetect.exe not found inside {signed_zip}")
+ if msi is None:
+ sys.exit(f"PySceneDetect-*.msi not found inside {signed_zip}")
+ print(f" signed exe: {exe.name} ({exe.stat().st_size:,} bytes)")
+ verify_authenticode(exe)
+ print(f" signed msi: {msi.name} ({msi.stat().st_size:,} bytes)")
+ verify_authenticode(msi)
+ return exe, msi
+
+
+def extract_msi_tree(msi_path: Path, dest: Path) -> Path:
+ """Run `msiexec /a` to extract the .msi's installed file tree without
+ actually installing. Returns the directory containing scenedetect.exe
+ (the app root), which sits under TARGETDIR at the .aip's APPDIR depth."""
+ if sys.platform != "win32":
+ sys.exit("msiexec /a is Windows-only")
+ print(f"Extracting {msi_path.name} via msiexec /a...")
+ # /a = administrative install: file extraction only, no registry, no admin rights.
+ # /qn = silent. TARGETDIR must be absolute.
+ result = subprocess.run(
+ ["msiexec", "/a", str(msi_path), "/qn", f"TARGETDIR={dest}"],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ sys.exit(
+ f"msiexec /a failed (exit {result.returncode}): "
+ f"{result.stderr.strip() or result.stdout.strip()}"
+ )
+ exe = next((p for p in dest.rglob("scenedetect.exe")), None)
+ if exe is None:
+ sys.exit(f"scenedetect.exe not found anywhere under {dest} after msiexec /a")
+ tree = exe.parent
+ # `msiexec /a` writes an "administrative" copy of the .msi (and sometimes a
+ # `Cabs/` folder) into TARGETDIR alongside the extracted app files. When
+ # APPDIR == TARGETDIR (no nested install folder), these land inside the app
+ # tree and would pollute the portable .zip. Strip them.
+ for stray in tree.glob("*.msi"):
+ print(f" stripping admin-install artifact: {stray.name}")
+ stray.unlink()
+ cabs_dir = tree / "Cabs"
+ if cabs_dir.is_dir():
+ print(" stripping admin-install artifact: Cabs/")
+ shutil.rmtree(cabs_dir)
+ print(f" app tree: {tree.relative_to(dest)}/ ({sum(1 for _ in tree.rglob('*')):,} entries)")
+ return tree
+
+
+def build_portable_zip(tree: Path, zip_path: Path, sevenz: Path) -> None:
+ """Pack `tree`'s top-level contents into a Deflate .zip using the same
+ flags AppVeyor's stage_windows_dist.py uses for the portable distribution."""
+ if zip_path.exists():
+ zip_path.unlink()
+ print(f"Building {zip_path.name} (zip / Deflate / mx=9 / mt=on)...")
+ # -mm=Deflate (not LZMA): Windows Explorer's built-in "Extract All" only
+ # supports Deflate-compressed zips; LZMA needs 7-Zip/WinRAR. Portable .zip
+ # ships to end users on clean Windows, so compat trumps ratio here.
+ # -mfb=258 -mpass=15: max-out Deflate tuning (slow, but once per release).
+ # -mmt=on: 7z parallelizes Deflate across files (not within a file), so
+ # the docs/ + thirdparty/ tree gets a real speedup; the two big binaries
+ # (scenedetect.exe, ffmpeg.exe) still each compress on a single thread.
+ # Pass top-level entries (not '*') so we don't depend on shell globbing.
+ entries = sorted(p.name for p in tree.iterdir())
+ subprocess.run(
+ [
+ str(sevenz),
+ "a",
+ "-tzip",
+ "-mm=Deflate",
+ "-mx=9",
+ "-mfb=258",
+ "-mpass=15",
+ "-mmt=on",
+ str(zip_path),
+ *entries,
+ ],
+ cwd=tree,
+ check=True,
+ capture_output=True,
+ )
+ print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
+
+
+def write_manifests(staging: Path, portable_zip: Path, msi: Path) -> None:
+ print(f"Hashing {portable_zip.name}...")
+ portable_digest = sha256_file(portable_zip)
+ print(f"Hashing {msi.name}...")
+ msi_digest = sha256_file(msi)
+
+ manifest = {
+ "version": VERSION,
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ "bundles": {
+ "msi": {
+ "path": msi.name,
+ "size": msi.stat().st_size,
+ "sha256": msi_digest,
+ },
+ "portable_zip": {
+ "path": portable_zip.name,
+ "size": portable_zip.stat().st_size,
+ "sha256": portable_digest,
+ "contents": hash_zip_contents(portable_zip),
+ },
+ },
+ }
+
+ manifest_path = staging / f"PySceneDetect-{VERSION}-win64.manifest.json"
+ sums_path = staging / "SHA256SUMS"
+ manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+ sums_path.write_text(
+ f"{msi_digest} {msi.name}\n{portable_digest} {portable_zip.name}\n",
+ encoding="utf-8",
+ )
+ print(f"Wrote {manifest_path.name}")
+ print(f"Wrote {sums_path.name}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
+ parser.add_argument(
+ "--staging-dir",
+ type=Path,
+ default=REPO_DIR / "dist" / "signed",
+ help="Directory holding scenedetect-signed.zip.",
+ )
+ args = parser.parse_args()
+
+ staging = args.staging_dir.resolve()
+ if not staging.is_dir():
+ sys.exit(f"{staging} not found")
+
+ signed_bundle = staging / "scenedetect-signed.zip"
+ if not signed_bundle.is_file():
+ sys.exit(f"{signed_bundle} not found")
+
+ sevenz = find_7zip()
+ print(f"Using 7-Zip: {sevenz}")
+ print(f"Staging dir: {staging}")
+ print(f"Version: {VERSION}")
+
+ portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip"
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ # Bundle holds the SignPath outputs; signed .exe is verified for the
+ # wrong-bundle check but otherwise unused (the .msi already ships its
+ # own signed copy of scenedetect.exe).
+ _signed_exe, signed_msi = extract_signed_bundle(signed_bundle, tmp_path / "bundle")
+ msi_dest = staging / signed_msi.name
+ shutil.copy2(signed_msi, msi_dest)
+ print(f"Copied signed MSI -> {msi_dest.name}")
+ msi_tree = extract_msi_tree(msi_dest, tmp_path / "msi-extract")
+ build_portable_zip(msi_tree, portable_zip, sevenz)
+ write_manifests(staging, portable_zip, msi_dest)
+
+ print()
+ print("Validating finalized artifacts...")
+ validate_release.run_all_checks(staging)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py
deleted file mode 100644
index 0626de16..00000000
--- a/scripts/generate_manifest.py
+++ /dev/null
@@ -1,174 +0,0 @@
-#
-# PySceneDetect: Python-Based Video Scene Detector
-# -------------------------------------------------------------------
-# [ Site: https://scenedetect.com ]
-# [ Docs: https://scenedetect.com/docs/ ]
-# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
-#
-# Copyright (C) 2026 Brandon Castellano .
-# PySceneDetect is licensed under the BSD 3-Clause License; see the
-# included LICENSE file, or visit one of the above pages for details.
-#
-"""Generate a SHA256 audit manifest for the Windows release artifacts.
-
-Walks the pyinstaller output tree, the built MSI, and the portable ZIP
-(if present), hashes every file, and writes:
-
- dist/PySceneDetect-X.Y.Z.manifest.json - structured per-file manifest
- dist/SHA256SUMS - flat sha256sum -c compatible
-
-Run after both `pyinstaller packaging/windows/scenedetect.spec` and the
-AdvancedInstaller MSI build have completed. Attach both outputs to the
-GitHub release so users can verify what they downloaded.
-"""
-
-import argparse
-import hashlib
-import json
-import re
-import sys
-import zipfile
-from datetime import datetime, timezone
-from pathlib import Path
-
-REPO_DIR = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(REPO_DIR))
-
-import scenedetect # noqa: E402
-
-
-def msi_version(raw: str) -> str:
- # Mirror scripts/update_installer.py - the artifact filename uses the
- # normalized X.Y.Z form, not the Python __version__ string.
- parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")]
- while len(parts) < 3:
- parts.append("0")
- return ".".join(parts[:4])
-
-
-VERSION = msi_version(scenedetect.__version__)
-DIST_DIR = REPO_DIR / "dist"
-PYINSTALLER_TREE = DIST_DIR / "scenedetect"
-MSI_PATH = REPO_DIR / "packaging" / "windows" / "installer" / f"PySceneDetect-{VERSION}-win64.msi"
-PORTABLE_ZIP = DIST_DIR / f"PySceneDetect-{VERSION}-portable.zip"
-
-CHUNK = 1 << 20 # 1 MiB
-
-
-def sha256_file(path: Path) -> str:
- h = hashlib.sha256()
- with path.open("rb") as f:
- for block in iter(lambda: f.read(CHUNK), b""):
- h.update(block)
- return h.hexdigest()
-
-
-def hash_tree(root: Path) -> list[dict]:
- entries = []
- for path in sorted(p for p in root.rglob("*") if p.is_file()):
- entries.append(
- {
- "path": path.relative_to(root).as_posix(),
- "size": path.stat().st_size,
- "sha256": sha256_file(path),
- }
- )
- return entries
-
-
-def hash_zip_contents(zip_path: Path) -> list[dict]:
- entries = []
- with zipfile.ZipFile(zip_path) as zf:
- for info in sorted(zf.infolist(), key=lambda i: i.filename):
- if info.is_dir():
- continue
- h = hashlib.sha256()
- with zf.open(info) as f:
- for block in iter(lambda: f.read(CHUNK), b""):
- h.update(block)
- entries.append(
- {
- "path": info.filename,
- "size": info.file_size,
- "sha256": h.hexdigest(),
- }
- )
- return entries
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
- parser.add_argument(
- "--out",
- type=Path,
- default=DIST_DIR / f"PySceneDetect-{VERSION}.manifest.json",
- help="Path to the JSON manifest output.",
- )
- parser.add_argument(
- "--sums",
- type=Path,
- default=DIST_DIR / "SHA256SUMS",
- help="Path to the flat sha256sum-compatible output.",
- )
- args = parser.parse_args()
-
- bundles: dict[str, dict] = {}
- top_level: list[tuple[str, str]] = [] # (sha256, relpath) for SHA256SUMS
-
- if PYINSTALLER_TREE.is_dir():
- print(f"Hashing pyinstaller tree: {PYINSTALLER_TREE}")
- bundles["pyinstaller_tree"] = {
- "path": PYINSTALLER_TREE.relative_to(REPO_DIR).as_posix(),
- "files": hash_tree(PYINSTALLER_TREE),
- }
- else:
- print(f"WARNING: {PYINSTALLER_TREE} missing - skipping.")
-
- if MSI_PATH.is_file():
- print(f"Hashing MSI: {MSI_PATH}")
- digest = sha256_file(MSI_PATH)
- bundles["msi"] = {
- "path": MSI_PATH.relative_to(REPO_DIR).as_posix(),
- "size": MSI_PATH.stat().st_size,
- "sha256": digest,
- }
- top_level.append((digest, MSI_PATH.name))
- else:
- print(f"WARNING: {MSI_PATH} missing - skipping.")
-
- if PORTABLE_ZIP.is_file():
- print(f"Hashing portable zip: {PORTABLE_ZIP}")
- digest = sha256_file(PORTABLE_ZIP)
- bundles["portable_zip"] = {
- "path": PORTABLE_ZIP.relative_to(REPO_DIR).as_posix(),
- "size": PORTABLE_ZIP.stat().st_size,
- "sha256": digest,
- "contents": hash_zip_contents(PORTABLE_ZIP),
- }
- top_level.append((digest, PORTABLE_ZIP.name))
- else:
- print(f"WARNING: {PORTABLE_ZIP} missing - skipping.")
-
- if not bundles:
- sys.exit("No artifacts found to hash.")
-
- manifest = {
- "version": VERSION,
- "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
- "bundles": bundles,
- }
-
- args.out.parent.mkdir(parents=True, exist_ok=True)
- args.out.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
- print(f"Wrote {args.out}")
-
- if top_level:
- args.sums.write_text(
- "".join(f"{sha} {name}\n" for sha, name in top_level),
- encoding="utf-8",
- )
- print(f"Wrote {args.sums}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/stage_windows_dist.py b/scripts/stage_windows_dist.py
index 690497d6..e59314d2 100644
--- a/scripts/stage_windows_dist.py
+++ b/scripts/stage_windows_dist.py
@@ -19,9 +19,12 @@
python scripts/stage_windows_dist.py --ffmpeg-dir
python scripts/update_installer.py --sync-files
AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip
- python scripts/generate_manifest.py
```
+After SignPath returns the signed bundle, run `scripts/finalize_windows_dist.py`
+locally to swap in the signed exe, repack the portable .zip, and emit the
+SHA256 manifests.
+
This script assumes it is run on a Windows machine.
"""
@@ -150,8 +153,8 @@ def stage_thirdparty_licenses() -> None:
def make_portable_zip(version: str) -> None:
- zip_path = DIST_DIR / f"PySceneDetect-{version}-portable.zip"
- manifest_path = DIST_DIR / f"PySceneDetect-{version}-portable.manifest.txt"
+ zip_path = DIST_DIR / f"PySceneDetect-{version}-win64.zip"
+ manifest_path = DIST_DIR / f"PySceneDetect-{version}-win64.manifest.txt"
if zip_path.exists():
zip_path.unlink()
print(f"Creating {zip_path.relative_to(REPO_DIR)}...")
diff --git a/scripts/validate_release.py b/scripts/validate_release.py
new file mode 100644
index 00000000..a0bc7276
--- /dev/null
+++ b/scripts/validate_release.py
@@ -0,0 +1,441 @@
+#
+# PySceneDetect: Python-Based Video Scene Detector
+# -------------------------------------------------------------------
+# [ Site: https://scenedetect.com ]
+# [ Docs: https://scenedetect.com/docs/ ]
+# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
+#
+# Copyright (C) 2026 Brandon Castellano .
+# PySceneDetect is licensed under the BSD 3-Clause License; see the
+# included LICENSE file, or visit one of the above pages for details.
+#
+"""Validate finalized Windows release artifacts.
+
+Runs against the staging directory produced by `scripts/finalize_windows_dist.py`
+(default `dist/signed/`) and verifies the artifacts that go up to a GitHub
+release. Catches regressions that only manifest in the post-build artifact, not
+in unit tests:
+
+ 1. Filename presence and `-win64` suffix consistency
+ 2. SHA256 of `.zip` and `.msi` matches `SHA256SUMS` and `manifest.json`,
+ and per-file hashes inside the portable .zip match the manifest
+ 3. Authenticode signatures on the `.msi` and the `scenedetect.exe` inside
+ the portable `.zip`
+ 4. MSI / portable-zip parity: every file in the portable .zip exists in
+ the MSI (matched by SHA256, name-agnostic to tolerate MSI mangling)
+ 5. Frozen `.exe` smoke tests:
+ - `scenedetect.exe version` prints the expected version
+ - No required dependency is reported as "Not Installed"
+ - A short `detect-content` invocation succeeds (skipped if the test
+ video resource is absent)
+ - Default error path produces a clean error, not a Python traceback
+
+Re-run standalone after fixing any failure:
+ python scripts/validate_release.py [--staging-dir DIR]
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+
+REPO_DIR = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_DIR))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from _release_common import ( # noqa: E402
+ find_7zip,
+ hash_zip_contents,
+ msi_version,
+ sha256_file,
+ verify_authenticode,
+)
+
+import scenedetect # noqa: E402
+
+VERSION = msi_version(scenedetect.__version__)
+
+# Mirrors `third_party_packages` in `scenedetect/platform.py:get_system_version_info()`.
+# Keep these two lists in sync: any package added there should be classified here as
+# either REQUIRED (must report a version in the frozen .exe) or OPTIONAL (one of a
+# mutually-exclusive pair that legitimately reports "Not Installed" in the bundle).
+REQUIRED_PACKAGES = (
+ "scenedetect",
+ "av",
+ "click",
+ "imageio",
+ "imageio-ffmpeg",
+ "moviepy",
+ "numpy",
+ "platformdirs",
+ "tqdm",
+)
+# Exactly one of these must report a version. The frozen Windows build ships
+# `opencv-python-headless` only, so `opencv-python` legitimately reports "Not Installed".
+OPENCV_VARIANTS = ("opencv-python", "opencv-python-headless")
+
+NOT_INSTALLED = "Not Installed"
+
+
+def fail(message: str) -> None:
+ """Print FAIL marker and bubble out as a SystemExit so finalize stops."""
+ sys.exit(f"VALIDATION FAILED: {message}")
+
+
+def section(name: str) -> None:
+ print()
+ print(f"[{name}]")
+
+
+def check_filenames(staging: Path) -> tuple[Path, Path, Path]:
+ """Step 1: required artifacts present, no stray inconsistent suffixes."""
+ section("Filenames")
+ portable_zip = staging / f"PySceneDetect-{VERSION}-win64.zip"
+ msi = staging / f"PySceneDetect-{VERSION}-win64.msi"
+ manifest = staging / f"PySceneDetect-{VERSION}-win64.manifest.json"
+ sums = staging / "SHA256SUMS"
+
+ for required in (portable_zip, msi, manifest, sums):
+ if not required.is_file():
+ fail(f"missing required artifact: {required.name}")
+ print(f" found {required.name}")
+
+ # Reject filename patterns that proved problematic during v0.7 release smoke testing.
+ # Both bugs were caused by inconsistent suffixes between portable .zip and .msi.
+ stray_suffixed = list(staging.glob("PySceneDetect-*-portable.zip"))
+ if stray_suffixed:
+ fail(
+ "found stale '-portable' artifacts (inconsistency caught in commit 550a5ad): "
+ + ", ".join(p.name for p in stray_suffixed)
+ )
+ for zip_path in staging.glob("PySceneDetect-*.zip"):
+ # Allow the canonical name + the .unsigned.zip backup written by finalize.
+ if zip_path == portable_zip or zip_path.name.endswith(".unsigned.zip"):
+ continue
+ fail(
+ f"unexpected portable .zip without '-win64' suffix: {zip_path.name} "
+ "(suffix inconsistency caught in commit 9421592)"
+ )
+ for msi_path in staging.glob("PySceneDetect-*.msi"):
+ if msi_path == msi:
+ continue
+ fail(
+ f"unexpected stray MSI: {msi_path.name} "
+ "(only one canonical PySceneDetect-X.Y.Z-win64.msi expected)"
+ )
+
+ return portable_zip, msi, manifest
+
+
+def check_hashes(staging: Path, portable_zip: Path, msi: Path, manifest_path: Path) -> dict:
+ """Step 2: SHA256 of .zip / .msi matches SHA256SUMS and manifest.json,
+ and per-file hashes inside the portable .zip match the manifest."""
+ section("Hashes")
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+
+ if manifest.get("version") != VERSION:
+ fail(f"manifest version {manifest.get('version')!r} != expected {VERSION!r}")
+
+ portable_actual = sha256_file(portable_zip)
+ msi_actual = sha256_file(msi)
+ print(f" {portable_zip.name}: {portable_actual}")
+ print(f" {msi.name}: {msi_actual}")
+
+ if manifest["bundles"]["portable_zip"]["sha256"] != portable_actual:
+ fail(f"manifest portable_zip sha256 mismatch ({portable_zip.name})")
+ if manifest["bundles"]["msi"]["sha256"] != msi_actual:
+ fail(f"manifest msi sha256 mismatch ({msi.name})")
+
+ sums_text = (staging / "SHA256SUMS").read_text(encoding="utf-8")
+ expected_lines = {
+ f"{msi_actual} {msi.name}",
+ f"{portable_actual} {portable_zip.name}",
+ }
+ actual_lines = {line.strip() for line in sums_text.splitlines() if line.strip()}
+ if expected_lines != actual_lines:
+ fail(
+ "SHA256SUMS does not match recomputed digests.\n"
+ f" expected: {sorted(expected_lines)}\n"
+ f" actual: {sorted(actual_lines)}"
+ )
+ print(" SHA256SUMS matches")
+
+ print(f" re-hashing {portable_zip.name} contents...")
+ actual_contents = hash_zip_contents(portable_zip)
+ expected_contents = manifest["bundles"]["portable_zip"]["contents"]
+ actual_by_path = {entry["path"]: entry for entry in actual_contents}
+ expected_by_path = {entry["path"]: entry for entry in expected_contents}
+ if actual_by_path.keys() != expected_by_path.keys():
+ only_actual = sorted(actual_by_path.keys() - expected_by_path.keys())
+ only_manifest = sorted(expected_by_path.keys() - actual_by_path.keys())
+ fail(
+ "manifest contents file list does not match portable .zip:\n"
+ f" only in zip: {only_actual}\n"
+ f" only in manifest: {only_manifest}"
+ )
+ for path, expected in expected_by_path.items():
+ actual = actual_by_path[path]
+ if actual["sha256"] != expected["sha256"] or actual["size"] != expected["size"]:
+ fail(f"manifest content mismatch for {path}: {expected} vs {actual}")
+ print(f" manifest matches all {len(actual_by_path)} entries inside portable .zip")
+ return manifest
+
+
+def check_signatures(portable_zip: Path, msi: Path) -> None:
+ """Step 3: Authenticode on .msi and on scenedetect.exe inside the portable .zip."""
+ section("Signatures")
+ print(f" verifying {msi.name}")
+ verify_authenticode(msi)
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ with zipfile.ZipFile(portable_zip) as zf:
+ try:
+ zf.extract("scenedetect.exe", tmp_path)
+ except KeyError:
+ fail(f"scenedetect.exe not found at root of {portable_zip.name}")
+ exe_path = tmp_path / "scenedetect.exe"
+ print(f" verifying scenedetect.exe inside {portable_zip.name}")
+ verify_authenticode(exe_path)
+
+
+def _hashes_in_dir(root: Path) -> set[str]:
+ return {sha256_file(p) for p in root.rglob("*") if p.is_file()}
+
+
+def check_msi_zip_parity(portable_zip: Path, msi: Path, sevenz: Path) -> None:
+ """Step 4: every file in the portable .zip should exist (by content)
+ inside the MSI. We compare SHA256 sets to be name-agnostic - 7-Zip's MSI
+ extraction can mangle filenames, so name-by-name diffs are unreliable, but
+ content hashes are exact."""
+ section("MSI / portable-zip parity")
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ msi_dir = tmp_path / "msi"
+ zip_dir = tmp_path / "zip"
+ msi_dir.mkdir()
+ zip_dir.mkdir()
+
+ # Extract MSI (which may produce inner .cab archives that themselves need
+ # extracting to recover the actual installed file tree).
+ print(f" extracting {msi.name} with 7-Zip...")
+ subprocess.run(
+ [str(sevenz), "x", str(msi), f"-o{msi_dir}", "-y"],
+ check=True,
+ capture_output=True,
+ )
+ cabs = list(msi_dir.rglob("*.cab"))
+ for cab in cabs:
+ print(f" expanding inner archive: {cab.name}")
+ subprocess.run(
+ [str(sevenz), "x", str(cab), f"-o{cab.parent}", "-y"],
+ check=True,
+ capture_output=True,
+ )
+ cab.unlink()
+
+ print(f" extracting {portable_zip.name}...")
+ with zipfile.ZipFile(portable_zip) as zf:
+ zf.extractall(zip_dir)
+
+ msi_hashes = _hashes_in_dir(msi_dir)
+ zip_hashes = _hashes_in_dir(zip_dir)
+ missing_from_msi = zip_hashes - msi_hashes
+ if missing_from_msi:
+ # Re-walk the portable zip to attach names to the missing hashes.
+ zip_by_hash = {}
+ for p in zip_dir.rglob("*"):
+ if p.is_file():
+ zip_by_hash.setdefault(sha256_file(p), p.relative_to(zip_dir).as_posix())
+ named = sorted(zip_by_hash.get(h, h) for h in missing_from_msi)
+ fail(
+ f"{len(missing_from_msi)} file(s) present in portable .zip but not in MSI:\n"
+ + "\n".join(f" {n}" for n in named[:25])
+ + (f"\n ... ({len(named) - 25} more)" if len(named) > 25 else "")
+ )
+ print(
+ f" all {len(zip_hashes)} files in portable .zip are present in MSI "
+ f"({len(msi_hashes)} files in MSI total)"
+ )
+
+
+def _parse_packages_section(version_output: str) -> dict[str, str]:
+ """Parse the 'Packages' section of `scenedetect version` output."""
+ packages: dict[str, str] = {}
+ in_section = False
+ for raw in version_output.splitlines():
+ line = raw.rstrip()
+ if not in_section:
+ if line.strip() == "Packages":
+ in_section = True
+ continue
+ # Section ends on blank line, separator, or next header.
+ if not line.strip() or line.strip().startswith("---") or line.strip() == "Tools":
+ if line.strip() == "Tools":
+ break
+ continue
+ # Format: "". Split on first run of >=2 spaces.
+ parts = line.split(None, 1)
+ if len(parts) != 2:
+ continue
+ name, value = parts[0].strip(), parts[1].strip()
+ packages[name] = value
+ return packages
+
+
+def check_frozen_exe(portable_zip: Path) -> None:
+ """Step 5: extract portable .zip, run scenedetect.exe, verify version,
+ package detection, smoke detect, and clean error path."""
+ section("Frozen .exe smoke tests")
+ if sys.platform != "win32":
+ print(" (skipping .exe smoke tests on non-Windows)")
+ return
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ with zipfile.ZipFile(portable_zip) as zf:
+ zf.extractall(tmp_path)
+ exe = tmp_path / "scenedetect.exe"
+ if not exe.is_file():
+ fail(f"scenedetect.exe not found at root of {portable_zip.name}")
+
+ # 5a. `version` prints VERSION and well-formed package table.
+ result = subprocess.run(
+ [str(exe), "version"],
+ capture_output=True,
+ text=True,
+ check=False,
+ cwd=tmp_path,
+ )
+ if result.returncode != 0:
+ fail(f"`scenedetect.exe version` exited {result.returncode}\n{result.stderr}")
+ packages = _parse_packages_section(result.stdout)
+ scenedetect_reported = packages.get("scenedetect", "")
+ # Normalize both sides through msi_version() so a raw __version__ of "0.7"
+ # matches the artifact-name VERSION of "0.7.0" (mirrors the same
+ # normalization scripts/update_installer.py applies to filenames).
+ if msi_version(scenedetect_reported) != VERSION:
+ fail(
+ f"`scenedetect.exe version` reports scenedetect=={scenedetect_reported!r}, "
+ f"expected {VERSION!r} (raw __version__ normalized)"
+ )
+ print(f" scenedetect=={scenedetect_reported}")
+
+ # 5b. No required dependency reports "Not Installed" - the bug fixed in c6a4145.
+ broken = [name for name in REQUIRED_PACKAGES if packages.get(name) == NOT_INSTALLED]
+ if broken:
+ fail(
+ "frozen .exe reports required packages as 'Not Installed' "
+ "(commit c6a4145 regression):\n " + ", ".join(broken)
+ )
+ opencv_present = [
+ v for v in OPENCV_VARIANTS if packages.get(v, NOT_INSTALLED) != NOT_INSTALLED
+ ]
+ if not opencv_present:
+ fail(
+ "neither opencv-python nor opencv-python-headless reported a version "
+ "(at least one must be present in the bundle)"
+ )
+ print(f" opencv variant present: {opencv_present[0]}=={packages[opencv_present[0]]}")
+ for name in REQUIRED_PACKAGES:
+ print(f" {name}=={packages[name]}")
+
+ # 5c. Functional smoke: short detect-content run on the test video, if available.
+ test_video = REPO_DIR / "tests" / "resources" / "testvideo.mp4"
+ if test_video.is_file():
+ out_dir = tmp_path / "smoke_output"
+ out_dir.mkdir()
+ print(f" running detect-content on {test_video.name}...")
+ result = subprocess.run(
+ [
+ str(exe),
+ "-i",
+ str(test_video),
+ "-o",
+ str(out_dir),
+ "detect-content",
+ "time",
+ "-e",
+ "2s",
+ "list-scenes",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ cwd=tmp_path,
+ )
+ if result.returncode != 0:
+ fail(
+ f"detect-content smoke run exited {result.returncode}\n"
+ f" stdout: {result.stdout}\n stderr: {result.stderr}"
+ )
+ outputs = list(out_dir.iterdir())
+ if not outputs:
+ fail("detect-content smoke run produced no output files")
+ print(f" detect-content OK ({len(outputs)} output file(s))")
+ else:
+ print(
+ f" (skipping detect-content smoke; {test_video.relative_to(REPO_DIR)} "
+ "not present locally)"
+ )
+
+ # 5d. Clean error path: SCENEDETECT_DEBUG unset must produce a logger-formatted
+ # error, not a Python traceback. Catches the __debug__ regression in c6a4145
+ # (PyInstaller's -O bytecode makes `if __debug__:` always-False, so the wrong
+ # branch fired and tracebacks leaked to end users).
+ nonexistent = tmp_path / "definitely-not-a-video.mp4"
+ clean_env = dict(os.environ)
+ clean_env.pop("SCENEDETECT_DEBUG", None)
+ result = subprocess.run(
+ [str(exe), "-i", str(nonexistent), "detect-content"],
+ capture_output=True,
+ text=True,
+ check=False,
+ cwd=tmp_path,
+ env=clean_env,
+ )
+ if result.returncode == 0:
+ fail("error path: scenedetect.exe exited 0 on a missing input file")
+ if "Traceback" in result.stderr or "Traceback" in result.stdout:
+ fail(
+ "error path: scenedetect.exe surfaced a Python traceback to the user "
+ "(commit c6a4145 __debug__ regression):\n"
+ f" stderr: {result.stderr.strip()[:500]}"
+ )
+ print(" error path: clean exit (no traceback)")
+
+
+def run_all_checks(staging: Path) -> None:
+ """Entrypoint shared with `finalize_windows_dist.py`. Raises SystemExit on failure."""
+ if not staging.is_dir():
+ fail(f"staging directory not found: {staging}")
+ print(f"Validating release artifacts in: {staging}")
+ print(f"Expected version: {VERSION}")
+
+ portable_zip, msi, manifest_path = check_filenames(staging)
+ check_hashes(staging, portable_zip, msi, manifest_path)
+ check_signatures(portable_zip, msi)
+ sevenz = find_7zip()
+ check_msi_zip_parity(portable_zip, msi, sevenz)
+ check_frozen_exe(portable_zip)
+
+ print()
+ print("All validation checks passed.")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
+ parser.add_argument(
+ "--staging-dir",
+ type=Path,
+ default=REPO_DIR / "dist" / "signed",
+ help="Directory containing finalized artifacts (default: dist/signed/).",
+ )
+ args = parser.parse_args()
+ run_all_checks(args.staging_dir.resolve())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/conftest.py b/tests/conftest.py
index a8823249..95e8b7b4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -32,6 +32,12 @@
import pytest
+# Surface unhandled exceptions and KeyboardInterrupt as raw tracebacks during tests so pytest
+# (and any debugger) sees the original failure instead of the logger-formatted output the CLI
+# uses for end users. Read by `scenedetect.platform.DEBUG_MODE`. `setdefault` lets a developer
+# override (e.g. `SCENEDETECT_DEBUG=` to mimic end-user behavior in a specific test run).
+os.environ.setdefault("SCENEDETECT_DEBUG", "1")
+
#
# Helper Functions
#
diff --git a/website/pages/changelog.md b/website/pages/changelog.md
index 15cb1d08..16606ecf 100644
--- a/website/pages/changelog.md
+++ b/website/pages/changelog.md
@@ -2,6 +2,116 @@
Releases
==========================================================
+
+## PySceneDetect 0.7
+
+### 0.7 (May 3, 2026)
+
+#### Release Notes
+
+PySceneDetect 0.7 is a **major breaking release** which overhauls how timestamps are handled. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs.
+
+Care was taken to minimize changes for most common API uses, however more advanced use cases may run into breaking changes. Please review [the Migration Guide](https://www.scenedetect.com/docs/0.7/api/migration_guide.html) when updating from v0.6. Minimum supported Python version is now **Python 3.10**.
+
+#### CLI Changes
+
+- [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters
+- [feature] All CLI options which used to accept frame numbers only now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) [#531](https://github.com/Breakthrough/PySceneDetect/issues/531)
+- [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156)
+- [feature] New `save-qp` command writes a QP file with scene boundary frame numbers, suitable for forcing keyframes at scene cuts in x264/x265 [#448](https://github.com/Breakthrough/PySceneDetect/issues/448)
+- [feature] New `save-html` command replaces the deprecated `export-html`; the prior command remains as an alias and emits a deprecation warning [#518](https://github.com/Breakthrough/PySceneDetect/issues/518)
+- [feature] Add `save-edl` option `--start-timecode`/`-s` to providde a custom start timecode for generated EDLs, supports SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF` input [#515](https://github.com/Breakthrough/PySceneDetect/issues/515)
+- [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision
+- [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496)
+- [feature] The MoviePy backend now supports overriding the source frame rate via `-f`/`--frame-rate` (and the `VideoStreamMoviePy(frame_rate=...)` API), bringing it in line with the OpenCV and PyAV backends
+- [bugfix] `detect-threshold` cut frame numbers are now backend-deterministic; previously the cut could differ by 1 frame between PyAV and OpenCV when the fade midpoint landed on a `.5` rounding boundary (PyAV uses sub-microsecond PTS, OpenCV uses millisecond-truncated `CAP_PROP_POS_MSEC`)
+- [breaking] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command (use `-c`/`--min-content-val` instead)
+- [breaking] Rename `-f/--framerate` to `-f/--frame-rate` as part of VFR overhaul (legacy `--framerate` form is preserved as a hidden alias but will be removed in v0.8)
+- [general] Support `SCENEDETECT_DEBUG` environment variable to control how exceptions and debugging are handled. Unhandled exceptions and `Ctrl+C` now produce a logger-formatted error message and exit cleanly with code 1 instead of dumping a raw Python traceback. Set `SCENEDETECT_DEBUG=1` to ensure all exceptions are re-raised instead of being logged. In both cases, the program will exit with a non-zero exit code.
+
+#### API Changes
+
+**VFR & Timestamp Overhaul:**
+
+ * Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only)
+ * `write_scene_list_edl` accepts an optional `start_timecode` parameter (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) that is added to every event's source and record columns [#515](https://github.com/Breakthrough/PySceneDetect/issues/515)
+ * Add new `Timecode` type to represent frame timings in terms of the video's source timebase
+ * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information
+ * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position`
+ * `VideoStream.frame_rate` now returns `Fraction` instead of `float`
+ * Framerates are now stored as rational `Fraction` values (e.g. `Fraction(24000, 1001)` instead of `23.976`) to avoid float precision loss
+ * Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values
+ * `FrameTimecode.frame_num` is now approximate for VFR video (based on PTS-derived time)
+ * Add `frame_rate` property (returns exact `Fraction`) as the canonical replacement for `framerate` (returns `float`) in `FrameTimecode` and `VideoStream`
+ * For CFR sources, both properties represent the same rate, i.e. `time_base` equals `1 / frame_rate` for CFR sources [#548](https://github.com/Breakthrough/PySceneDetect/issues/548)
+ * Add `frame_rate` keyword argument to `open_video()` and the `VideoStreamCv2`, `VideoCaptureAdapter`, `VideoStreamAv`, and `VideoStreamMoviePy` constructors as the canonical replacement for `framerate` [#548](https://github.com/Breakthrough/PySceneDetect/issues/548); accepts `float | Fraction | None`. The legacy `framerate` keyword is retained as a deprecated alias and is ignored when `frame_rate` is provided
+ * Add `equal_frame_rate(other)` method as the canonical replacement for `equal_framerate(fps)`
+
+**General:**
+
+ * Type hints: audit and overhaul: first-party code is now clean with Pyright basic mode, migrated deprecated type hints to comply with PEP 585
+ * Code quality: expand static analysis rules, audit and cleanup existing suppressions
+ * Packaging: modernized to comply with PEP 621, make `opencv-python` a requirement, add separate `scenedetect-headless` variant instead
+
+**Detector Interface:**
+
+ * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168):
+ * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` (the `post_process` signature on the abstract base is now consistently typed as `FrameTimecode` to match its concrete-detector overrides; the prior `int` annotation did not reflect the actual runtime value)
+ * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` formally accept either `FrameTimecode` or `int` (the `int` form is retained for compatibility with the deprecated `load_from_csv()` path, which keys metrics by integer frame number)
+ * `StatsManager.load_from_csv()` and `save_images()` `output_dir` now accept `os.PathLike` (e.g. `pathlib.Path`) in addition to `str`
+ * `SceneManager.detect_scenes()` `duration` and `end_time` formally accept `int` (frames), `float` (seconds), `str` (timecode), or `FrameTimecode` - matching the documented and runtime-supported behavior
+ * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html)
+ * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called
+ * Remove `SceneDetector.is_processing_required()` method
+ * Remove `SceneDetector.stats_manager_required` property, no longer required
+ * Remove deprecated `SparseSceneDetector` interface
+ * Detector `min_scene_len` and `save_images()` `frame_margin` arguments now accept seconds (`float`) and timecode strings (e.g. `"0.6s"`, `"00:00:00.600"`) in addition to a frame count (`int`); these are evaluated using the source video's timing for correct behavior on VFR videos [#531](https://github.com/Breakthrough/PySceneDetect/issues/531)
+
+**Module Reorganization:**
+
+ * `scenedetect.scene_detector` moved to `scenedetect.detector`
+ * `scenedetect.frame_timecode` moved to `scenedetect.common`
+ * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463)
+ * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463)
+
+**FrameTimecode:**
+
+ * Add properties to access `frame_num`, `frame_rate`, and `seconds` instead of getter methods
+ * `frame_num` and `frame_rate` are now read-only properties (construct a new `FrameTimecode` to change them)
+ * Remove `FrameTimecode.previous_frame()` method
+ * Deprecated functionality preserved from v0.6 now uses the `warnings` module to emit runtime deprecation warnings, these features will be removed in v0.8
+ * Soft-deprecate `framerate` property and `equal_framerate()` method via docstring; the legacy forms will continue to work until v0.8 when they will be upgraded to `DeprecationWarning` before removal in v0.9
+
+**Removals:**
+
+ * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead
+ * Remove deprecated parameters `base_timecode` and `video_manager` from various functions
+ * Remove deprecated `SceneManager.get_event_list()` method
+ * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead)
+ * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead)
+ * Remove `advance` parameter from `VideoStream.read()`
+ * Remove `SceneDetector.stats_manager_required` property, no longer required
+ * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html)
+
+#### Windows Distribution
+
+ - [general] Updates to Windows distributions:
+ - av 14.2.0 -> 17.0.1
+ - click 8.1.8 -> 8.2.1
+ - imageio-ffmpeg 0.6.0
+ - moviepy 2.1.2 -> 2.2.1
+ - numpy 2.2.3 -> 2.4.4
+ - opencv-python-headless 4.11.0.86 -> 4.13.0.92
+ - platformdirs 4.3.6 -> 4.9.6
+ - tqdm 4.67.1 -> 4.67.3
+ - ffmpeg 8.0 -> 8.1
+ - [general] Reduced size of Windows distribution without affecting functionality
+ - [bugfix] Pressing `Ctrl+C` during scene detection in the bundled distribution now exits cleanly instead of surfacing the PyInstaller bootloader traceback
+
+
+----------------------------------------------------------------
+
+
## PySceneDetect 0.6
### PySceneDetect 0.6.7.1 (September 24, 2025)
@@ -662,106 +772,7 @@ Both the Windows installer and portable distributions now include signed executa
----------------------------------------------------------------
-Development
+
diff --git a/website/pages/docs.md b/website/pages/docs.md
index 346036b2..8840e94f 100644
--- a/website/pages/docs.md
+++ b/website/pages/docs.md
@@ -4,6 +4,14 @@
## Stable
* [latest](latest/)
+ * [v0.7](0.7/)
+
+## Development
+
+ * [head](head/)
+
+## Legacy
+
* [v0.6.7](0.6.7/)
* [v0.6.6](0.6.6/)
* [v0.6.5](0.6.5/)
@@ -11,7 +19,3 @@
* [v0.6.3](0.6.3/)
* [v0.6.2](0.6.2/)
* [v0.6.1](0.6.1/)
-
-## In Development
-
- * [head](head/)
diff --git a/website/pages/download.md b/website/pages/download.md
index e3197272..c5de587f 100644
--- a/website/pages/download.md
+++ b/website/pages/download.md
@@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/
## Windows Build (64-bit Only)
diff --git a/website/pages/index.md b/website/pages/index.md
index 916b2281..61b727d7 100644
--- a/website/pages/index.md
+++ b/website/pages/index.md
@@ -3,7 +3,7 @@