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)  
-

Latest Release: v0.6.7

-

  Release Date:  August 24, 2025

-  Installer  (recommended)      -  Portable .zip      +

Latest Release: v0.7

+

  Release Date:  May 3, 2026

+  Installer  (recommended)      +  Portable .zip        Getting Started
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 @@ PySceneDetect
-

  Latest Release: v0.6.7 (August 24, 2025)

+

  Latest Release: v0.7 (May 3, 2026)

  Download        Changelog        Documentation        Getting Started