From 9fa2c64a659ddd49bf06e046f92fa3a122bdaf49 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 Apr 2026 18:00:52 -0400 Subject: [PATCH 1/9] [docs] Update install instructions for new headless variant --- README.md | 3 ++- docs/index.rst | 2 +- packaging/package-info.rst | 2 +- website/pages/download.md | 10 +++++----- website/pages/faq.md | 11 +++++------ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6f1a5a18..29ecff0d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Video Cut Detection and Analysis Tool **Quick Install**: - pip install scenedetect[opencv] --upgrade + pip install scenedetect --upgrade # standard (depends on opencv-python) + pip install scenedetect-headless --upgrade # headless servers (depends on opencv-python-headless) Requires ffmpeg/mkvmerge for video splitting support. Windows builds (MSI installer/portable ZIP) can be found on [the download page](https://scenedetect.com/download/). diff --git a/docs/index.rst b/docs/index.rst index b258fb21..47bfe59e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,7 +8,7 @@ PySceneDetect Documentation Welcome to the PySceneDetect docs. The docs are split into two separate parts: one for the command-line interface (the `scenedetect` command) and another for the Python API (the `scenedetect` module). -You can install the latest release of PySceneDetect by running `pip install scenedetect[opencv]` or downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. +You can install the latest release of PySceneDetect by running `pip install scenedetect` (or `pip install scenedetect-headless` on servers without GUI libraries), or by downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. .. note:: diff --git a/packaging/package-info.rst b/packaging/package-info.rst index 61045c47..7fab8f37 100644 --- a/packaging/package-info.rst +++ b/packaging/package-info.rst @@ -23,7 +23,7 @@ Documentation: https://www.scenedetect.com/docs Github Repo: https://github.com/Breakthrough/PySceneDetect/ -Install: ``pip install --upgrade scenedetect[opencv]`` +Install: ``pip install --upgrade scenedetect`` (or ``scenedetect-headless`` for servers) ---------------------------------------------------------- diff --git a/website/pages/download.md b/website/pages/download.md index ffa78d12..1d9efbd3 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -9,13 +9,13 @@ PySceneDetect requires at least Python 3.10 or higher. ## Install via pip      
-

Including OpenCV (recommended):

-

pip install --upgrade scenedetect[opencv]

-

Including Headless OpenCV (servers):

-

pip install --upgrade scenedetect[opencv-headless]

+

Standard install (recommended):

+

pip install --upgrade scenedetect

+

Headless install (servers, no GUI libs):

+

pip install --upgrade scenedetect-headless

-PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi.org/project/scenedetect/). +PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/project/scenedetect/) (depends on `opencv-python`) or [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/) (depends on `opencv-python-headless`). Both ship the same `scenedetect` Python module — install whichever OpenCV variant suits your environment. ## Windows Build (64-bit Only)   diff --git a/website/pages/faq.md b/website/pages/faq.md index bcbb0224..18b1f72d 100644 --- a/website/pages/faq.md +++ b/website/pages/faq.md @@ -4,20 +4,19 @@ #### How can I fix `ImportError: No module named cv2`? -You need to install OpenCV for PySceneDetect to properly work. If you're using `pip`, you can install it as follows: +As of PySceneDetect 0.7, the OpenCV dependency is bundled with the install. The standard `scenedetect` package depends on `opencv-python`: ```md -pip install scenedetect[opencv] +pip install scenedetect ``` -Note that you may need to use a different/older version depending on your Python version. You can also use the headless package if you're running a server: - +For server environments without GUI libraries, install the headless variant instead, which depends on `opencv-python-headless`: ```md -pip install scenedetect[opencv-headless] +pip install scenedetect-headless ``` -Unlike calling `pip install opencv-python`, the above commands will download and install the correct OpenCV version based on the Python version you are running. +Both packages ship the same `scenedetect` Python module — you only need one. #### How can I enable video splitting support? From a02f4bee05fb16fa73c673b0a1d546fd3eca7dae Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 Apr 2026 20:52:06 -0400 Subject: [PATCH 2/9] [common] Calculate framerate as Fraction rather than using lookup table --- scenedetect/common.py | 31 ++++++++++++++----------------- tests/test_timecode.py | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 795c42bc..98aed3d4 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -91,37 +91,34 @@ MAX_FPS_DELTA: float = 1.0 / 1000000000.0 """Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec.""" +# `datetime.timedelta` does not expose seconds per minute/hour as constants, so we define our own. _SECONDS_PER_MINUTE = 60.0 _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE _MINUTES_PER_HOUR = 60.0 -# Common framerates mapped from their float representation to exact rational values. -_COMMON_FRAMERATES: dict[Fraction, Fraction] = { - Fraction(24000, 1001): Fraction(24000, 1001), # 23.976... - Fraction(30000, 1001): Fraction(30000, 1001), # 29.97... - Fraction(60000, 1001): Fraction(60000, 1001), # 59.94... - Fraction(120000, 1001): Fraction(120000, 1001), # 119.88... -} +# Tolerance for snapping a float value's framerate to an NTSC-derived rational (N * 1000/1001). +# e.g. 23.976 should be detected as 24000/1001, 29.97 should be detected as 30000/1001, etc. +_NTSC_DETECTION_TOLERANCE: float = 1e-3 def framerate_to_fraction(fps: float) -> Fraction: """Convert a float framerate to an exact rational Fraction. - Recognizes common NTSC framerates (23.976, 29.97, 59.94, 119.88) and maps them to their - exact rational representation (e.g. 24000/1001). For other values, uses limit_denominator - to find a clean rational approximation, or returns the exact integer fraction for whole - number framerates. + Detects NTSC-derived framerates of the form ``N * 1000/1001`` (e.g. 23.976 -> 24000/1001, + 29.97 -> 30000/1001, 47.952 -> 48000/1001) for any positive integer ``N`` and returns + their exact rational representation. Whole-number framerates are returned as + ``Fraction(N, 1)``. Other values fall back to ``limit_denominator(10000)`` for a clean + rational approximation. """ if fps <= MAX_FPS_DELTA: raise ValueError("Framerate must be positive and greater than zero.") - # Integer framerates are exact. if fps == int(fps): return Fraction(int(fps), 1) - # Check against known common framerates using limit_denominator to find the closest match. - candidate = Fraction(fps).limit_denominator(10000) - if candidate in _COMMON_FRAMERATES: - return _COMMON_FRAMERATES[candidate] - return candidate + # Invert fps = N * 1000/1001 to recover N, then verify within tolerance. + base = round(fps * 1001 / 1000) + if base > 0 and abs(base * 1000 / 1001 - fps) < _NTSC_DETECTION_TOLERANCE: + return Fraction(base * 1000, 1001) + return Fraction(fps).limit_denominator(10000) class Interpolation(Enum): diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 42d4a082..7e564a46 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -316,12 +316,34 @@ def test_ntsc_framerate_detection(): assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001) assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001) assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001) + assert framerate_to_fraction(119.88011988011988) == Fraction(120000, 1001) assert framerate_to_fraction(24.0) == Fraction(24, 1) assert framerate_to_fraction(30.0) == Fraction(30, 1) assert framerate_to_fraction(60.0) == Fraction(60, 1) assert framerate_to_fraction(25.0) == Fraction(25, 1) +def test_ntsc_framerate_detection_arbitrary_base(): + """NTSC detection should work for any base rate, not a hardcoded list (e.g. 48000/1001 + for HFR cinema).""" + assert framerate_to_fraction(47.952047952047955) == Fraction(48000, 1001) + assert framerate_to_fraction(239.76023976023975) == Fraction(240000, 1001) + + +def test_ntsc_framerate_detection_low_precision(): + """Low-precision float reports (e.g. truncated to 3 decimals) should still snap to the + NTSC rational.""" + assert framerate_to_fraction(23.976) == Fraction(24000, 1001) + assert framerate_to_fraction(29.97) == Fraction(30000, 1001) + + +def test_framerate_to_fraction_non_ntsc_fallback(): + """Non-NTSC, non-integer framerates should fall back to limit_denominator and not be + misclassified as NTSC.""" + # 24.5 is not near any N*1000/1001 within tolerance, so the limit_denominator path runs. + assert framerate_to_fraction(24.5) == Fraction(49, 2) + + def test_timecode_arithmetic_mixed_time_base(): """Arithmetic with FrameTimecodes using different time_bases should work.""" fps = Fraction(24000, 1001) From 8b4efe6218f19eac6104de652069ecdf13e455a7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 Apr 2026 20:57:52 -0400 Subject: [PATCH 3/9] [docs] Fix some migration guide errors --- docs/api/migration_guide.rst | 35 +++++++++++++++++------------------ docs/conf.py | 1 + pyproject.toml | 2 +- website/pages/changelog.md | 6 +++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index a4288bfe..01fa0d75 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -80,7 +80,7 @@ The ``frame_num`` parameter (``int``) has been replaced with ``timecode`` (:clas def process_frame(self, timecode: FrameTimecode, frame_img) -> List[FrameTimecode]: ... -The same change applies to ``post_process()``. If you need the frame number, use ``timecode.frame_num``. +The same change applies to ``post_process()``. Using units of time instead of frame numbers is critical for temporal accuracy. If you need the frame number, use ``timecode.frame_num`` to the timecode to an integer. ``SceneDetector`` is Now Abstract ----------------------------------------------------------------------- @@ -104,20 +104,19 @@ The following have been removed from the ``SceneDetector`` interface: Read-Only Properties ----------------------------------------------------------------------- -``frame_num`` and ``framerate`` are now read-only properties. To change them, construct a new ``FrameTimecode``: +:attr:`~scenedetect.common.FrameTimecode.frame_num` and :attr:`~scenedetect.common.FrameTimecode.framerate` are now read-only properties. To change them, construct a new ``FrameTimecode``: .. code:: python - # v0.6 - direct assignment - tc.frame_num = 100 # No longer works - - # v0.7 - construct new instance - tc = FrameTimecode(100, tc.framerate) + tc = FrameTimecode(0, 24.0) + # Can no longer reassign frame_num, must create a new FrameTimecode instead: + #tc.frame_num = 100 + tc = FrameTimecode(100, tc) New Properties ----------------------------------------------------------------------- -Access ``frame_num``, ``framerate``, and ``seconds`` as properties instead of getter methods: +Access :attr:`~scenedetect.common.FrameTimecode.frame_num`, :attr:`~scenedetect.common.FrameTimecode.framerate`, and :attr:`~scenedetect.common.FrameTimecode.seconds` as properties instead of getter methods: .. code:: python @@ -139,7 +138,7 @@ Framerate and Timestamp Changes Rational Framerates ----------------------------------------------------------------------- -``VideoStream.frame_rate`` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: +:attr:`~scenedetect.video_stream.VideoStream.frame_rate` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: .. code:: python @@ -151,16 +150,16 @@ Rational Framerates PTS-Backed Timestamps ----------------------------------------------------------------------- -All backends now return presentation timestamp (PTS) backed values from ``VideoStream.position``. This enables correct handling of VFR videos. +All backends now return presentation timestamp (PTS) backed values from :attr:`~scenedetect.video_stream.VideoStream.position`. This enables correct handling of VFR videos. -``FrameTimecode`` has new ``time_base`` and ``pts`` properties for accessing the underlying timing information. For VFR videos, ``frame_num`` is now an approximation based on PTS-derived time rather than a sequential count. +``FrameTimecode`` has new :attr:`~scenedetect.common.FrameTimecode.time_base` and :attr:`~scenedetect.common.FrameTimecode.pts` properties for accessing the underlying timing information. For VFR videos, :attr:`~scenedetect.common.FrameTimecode.frame_num` is now an approximation based on PTS-derived time rather than a sequential count. ======================================================================= ``StatsManager`` Changes ======================================================================= -The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now formally accept either a ``FrameTimecode`` or a plain ``int`` frame number for the timecode argument. Passing a ``FrameTimecode`` is preferred and matches the detector interface; the ``int`` form is retained for compatibility with the deprecated ``load_from_csv()`` path, which keys metrics by integer frame number. +The ``StatsManager`` methods :meth:`~scenedetect.stats_manager.StatsManager.get_metrics`, :meth:`~scenedetect.stats_manager.StatsManager.set_metrics`, and :meth:`~scenedetect.stats_manager.StatsManager.metrics_exist` now formally accept either a ``FrameTimecode`` or a plain ``int`` frame number for the timecode argument. Passing a ``FrameTimecode`` is preferred and matches the detector interface; 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()`` also accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str`` / ``bytes`` / file handles. @@ -169,21 +168,21 @@ The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics ``SceneDetector`` Annotation Fixes ======================================================================= -``SceneDetector.post_process()`` now declares its parameter as ``timecode: FrameTimecode`` (previously typed as ``int``). The method already received a ``FrameTimecode`` at runtime and concrete detectors (e.g. ``ThresholdDetector``, ``ContentDetector``) already used the ``FrameTimecode`` type - only the abstract-base-class annotation was inconsistent. No call-site changes are needed; this just brings the signature into agreement with the documented and actual behavior. +:meth:`~scenedetect.detector.SceneDetector.post_process` now declares its parameter as ``timecode: FrameTimecode`` (previously typed as ``int``). The method already received a ``FrameTimecode`` at runtime and concrete detectors (e.g. ``ThresholdDetector``, ``ContentDetector``) already used the ``FrameTimecode`` type - only the abstract-base-class annotation was inconsistent. No call-site changes are needed; this just brings the signature into agreement with the documented and actual behavior. ======================================================================= ``SceneManager.detect_scenes()`` Time Arguments ======================================================================= -The ``duration`` and ``end_time`` arguments now formally accept ``int`` (frames), ``float`` (seconds), ``str`` (timecode string, e.g. ``"00:00:05.000"``), or ``FrameTimecode``. The internal code already validated these forms; the annotation was previously narrower than the documented behavior. +The ``duration`` and ``end_time`` arguments of :meth:`~scenedetect.scene_manager.SceneManager.detect_scenes` now formally accept ``int`` (frames), ``float`` (seconds), ``str`` (timecode string, e.g. ``"00:00:05.000"``), or ``FrameTimecode``. The internal code already validated these forms; the annotation was previously narrower than the documented behavior. .. code:: python # All of these were always supported at runtime; now they type-check too: - scene_manager.detect_scenes(video=video, end_time=15.0) # seconds - scene_manager.detect_scenes(video=video, end_time=1500) # frames - scene_manager.detect_scenes(video=video, end_time="00:01:00") # timecode + scene_manager.detect_scenes(video, end_time=15.0) # seconds + scene_manager.detect_scenes(video, end_time=1500) # frames + scene_manager.detect_scenes(video, end_time="00:01:00") # timecode ======================================================================= @@ -212,7 +211,7 @@ The following deprecated APIs have been fully removed in v0.7: * - ``video_manager`` parameter (various functions) - Use ``video`` parameter instead * - ``SceneManager.get_event_list()`` - - Use ``SceneManager.get_cut_list()`` or ``SceneManager.get_scene_list()`` + - Use :meth:`~scenedetect.scene_manager.SceneManager.get_cut_list` or :meth:`~scenedetect.scene_manager.SceneManager.get_scene_list` * - ``AdaptiveDetector.get_content_val()`` - Use ``StatsManager`` to query metrics * - ``AdaptiveDetector(min_delta_hsv=...)`` diff --git a/docs/conf.py b/docs/conf.py index 954c2552..01075df5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.autodoc", + "sphinx_copybutton", ] autoclass_content = "both" diff --git a/pyproject.toml b/pyproject.toml index 3f9a7d2e..22b837e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ pyav = ["av>=9.2"] moviepy = ["moviepy"] dev = ["av>=9.2", "moviepy", "pytest>=7.0"] -docs = ["Sphinx==7.0.1"] +docs = ["Sphinx==7.0.1", "sphinx-copybutton==0.5.2"] website = ["mkdocs==1.5.2", "jinja2>=3.1.6"] [project.urls] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e8872fc9..1e66501e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -669,16 +669,16 @@ Development ### Release Notes -PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. 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. +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] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) -- [feature] `save-edl` accepts a new `--start-timecode`/`-s` flag (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) to stamp every event with a custom start timecode so generated EDLs align with the source media's on-screen timecode [#515](https://github.com/Breakthrough/PySceneDetect/issues/515) +- [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) - [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`) From 1dc1ac431f0381f38e1e3976ebb51bd0f66b1f4d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 Apr 2026 21:57:45 -0400 Subject: [PATCH 4/9] [dist] Automate installer file and version updates --- .gitignore | 1 + RELEASE-PLAN.md | 21 +++- appveyor.yml | 20 ++-- scripts/bump_installer.py | 130 ++++++++++++++++++++++++ scripts/generate_manifest.py | 174 ++++++++++++++++++++++++++++++++ scripts/stage_windows_dist.py | 184 ++++++++++++++++++++++++++++++++++ 6 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 scripts/bump_installer.py create mode 100644 scripts/generate_manifest.py create mode 100644 scripts/stage_windows_dist.py diff --git a/.gitignore b/.gitignore index 72e26afb..1d488715 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tests/resources/* *.m4v *.csv packaging/windows/.version_info +packaging/windows/installer/PySceneDetect.back*.aip benchmarks/BCC/*.mp4 *.txt benchmarks/RAI/*.mp4 diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index b7fb3257..269275a9 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -5,16 +5,16 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout ## 0. Branch setup -- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. -- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). +- [X] Create / fast-forward release branch: `releases/X.Y` off `main`. +- [X] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). ## 1. Code & version - [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Bump `ProductVersion` in `packaging/windows/installer/PySceneDetect.aip` (must match `__version__` - `scripts/pre_release.py --release` asserts this). +- [ ] Bump the installer project: `python scripts/bump_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. - [ ] No `-dev` / pre-release suffix on the version string for a final release. -> **Note:** `setup.cfg` reads the package version dynamically via `version = attr: scenedetect.__version__`, and `pyproject.toml` does not declare a `version` field. The single source of truth is `scenedetect/__init__.py`; the `.aip` is the only other place to keep in sync. +> **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync. ## 2. Docs @@ -42,7 +42,18 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] `python scripts/pre_release.py --release` passes (enforces `.aip` ↔ `__version__` parity, writes `packaging/windows/.version_info`). - [ ] `pyinstaller packaging/windows/scenedetect.spec` produces a working `scenedetect.exe` - run it against a sample video. +- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). - [ ] Build the MSI via Advanced Installer (`packaging/windows/installer/PySceneDetect.aip`); install into a clean Windows VM and run the CLI. +- [ ] After both `pyinstaller` and the MSI build are done (and the portable `.zip` is staged at `dist/PySceneDetect-X.Y.Z-portable.zip`), run `python scripts/generate_manifest.py` to produce `dist/PySceneDetect-X.Y.Z.manifest.json` (per-file SHA256 audit of every artifact) and `dist/SHA256SUMS` (flat `sha256sum -c` compatible). Both are attached to the GitHub release in step 7. + +> **GUI required for structural changes.** `scripts/bump_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: +> +> - Moving the .aip or its source tree (the build's `SourcePath` references are stored relative to the .aip and aren't rewritten by `/NewSync` - cf. the `dist/installer/` → `packaging/windows/installer/` move that broke the relative paths until they were edited in the GUI). +> - Adding/removing build configurations, features, or prerequisites. +> - Editing dialog layouts, branding bitmaps, install sequences, custom actions, file associations, or shortcuts. +> - Changing `UpgradeCode`, install directory layout (`APPDIR` location), or per-component attributes. +> +> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `bump_installer.py` afterwards if the version-identity fields need refreshing. ## 6. Cut the release @@ -55,7 +66,7 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] `publish-pypi.yml` ran on the tag and uploaded successfully. Verify both projects: https://pypi.org/project/scenedetect/ and https://pypi.org/project/scenedetect-headless/. - [ ] Smoke-test PyPI: in a fresh venv, `pip install scenedetect==X.Y.Z`; CLI launches and `pip show scenedetect` lists `opencv-python`. Repeat in a second venv with `pip install scenedetect-headless==X.Y.Z`; verify it lists `opencv-python-headless`. -- [ ] Create GitHub Release from the `vX.Y[.Z]` tag, body = changelog section, attach Windows installer MSI + portable `.zip`. +- [ ] Create GitHub Release from the `vX.Y[.Z]` tag, body = changelog section, attach Windows installer MSI + portable `.zip` + `PySceneDetect-X.Y.Z.manifest.json` + `SHA256SUMS` (both produced by `scripts/generate_manifest.py`). - [ ] Deploy website: `generate-website.yml` picks up the changelog / download page updates. - [ ] Deploy docs: `generate-docs.yml` publishes the new version. diff --git a/appveyor.yml b/appveyor.yml index 64b666ea..91c8defd 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -49,19 +49,13 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING WINDOWS EXE * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - # Build Windows .EXE and create portable .ZIP + # Build Windows .EXE and create portable .ZIP. The staging script copies + # ffmpeg.exe + LICENSE from --ffmpeg-dir, third-party licenses, the project + # LICENSE/README, and sphinx docs into dist/scenedetect/, then emits the + # portable .zip - keeps CI and local builds in sync (see scripts/stage_windows_dist.py). - python scripts/pre_release.py --release - pyinstaller packaging/windows/scenedetect.spec - - sphinx-build -b singlehtml docs dist/scenedetect/docs - - mkdir dist\scenedetect\thirdparty - - move LICENSE dist\scenedetect\ - - copy packaging\windows\LICENSE-PYTHON dist\scenedetect\thirdparty\ - - copy scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\ - - copy dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - - move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG - - cd dist/scenedetect - - 7z a ../scenedetect-win64.zip * - - cd ../.. + - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg --portable-zip - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING MSI INSTALLER * * @@ -109,8 +103,8 @@ test_script: - scenedetect.exe -i ../../tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s artifacts: - # Portable ZIP - - path: dist/scenedetect-win64.zip + # Portable ZIP (named PySceneDetect-X.Y.Z-portable.zip by stage_windows_dist.py) + - path: dist/PySceneDetect-*-portable.zip name: PySceneDetect-win64_portable # MSI Installer + .EXE Bundle for Signing - path: dist/scenedetect-signed.zip diff --git a/scripts/bump_installer.py b/scripts/bump_installer.py new file mode 100644 index 00000000..18c8f510 --- /dev/null +++ b/scripts/bump_installer.py @@ -0,0 +1,130 @@ +# +# 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. +# +"""Bump the AdvancedInstaller .aip project for a release. + +Usage: + python scripts/bump_installer.py # version bump only + python scripts/bump_installer.py --sync-files # also re-sync APPDIR + python scripts/bump_installer.py --version 0.7.0 # explicit version override + +The version-bump path rewrites ProductVersion / ProductCode / PackageFileName. +The --sync-files path additionally walks dist/scenedetect/ (pyinstaller output) +and rewrites the project's directory + component + file tables to match, +which is needed when bundled dependencies change. + +Both paths shell out to AdvancedInstaller.com so the .aip's invariants +(line endings, attribute ordering, GUID casing) stay intact. The CLI lives +under "C:\\Program Files (x86)\\Caphyon\\Advanced Installer ..\\bin\\x86\\". +Override discovery with the ADVINST environment variable. +""" + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) + +import scenedetect # noqa: E402 + +INSTALLER_AIP = REPO_DIR / "packaging" / "windows" / "installer" / "PySceneDetect.aip" +DIST_TREE = REPO_DIR / "dist" / "scenedetect" + + +def msi_version(raw: str) -> str: + # AdvancedInstaller's ProductVersion only accepts numeric X[.Y[.Z[.B]]]. + # Strip Python-style suffixes ("0.7-dev0" -> "0.7"; "1.0.0-rc1" -> "1.0.0") + # and pad to three components so the resulting MSI filename is consistent. + parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] + if not all(p.isdigit() for p in parts if p): + sys.exit(f"Cannot derive numeric MSI version from {raw!r}") + while len(parts) < 3: + parts.append("0") + return ".".join(parts[:4]) + + +def find_advinst() -> Path: + if env := os.environ.get("ADVINST"): + path = Path(env) + if not path.exists(): + sys.exit(f"ADVINST={env} does not exist.") + return path + candidates = sorted( + Path(r"C:\Program Files (x86)\Caphyon").glob( + "Advanced Installer*/bin/x86/AdvancedInstaller.com" + ) + ) + if not candidates: + sys.exit( + "AdvancedInstaller.com not found under C:\\Program Files (x86)\\Caphyon. " + "Set the ADVINST environment variable to its full path." + ) + return candidates[-1] + + +def run(advinst: Path, *edit_args: str, check: bool = True) -> int: + cmd = [str(advinst), "/edit", str(INSTALLER_AIP), *edit_args] + print(">", " ".join(cmd)) + return subprocess.run(cmd, check=check).returncode + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--sync-files", + action="store_true", + help="Re-sync APPDIR from dist/scenedetect/ (run pyinstaller first).", + ) + parser.add_argument( + "--version", + dest="version_override", + help="MSI version override (default: derived from scenedetect.__version__).", + ) + args = parser.parse_args() + + raw_version = args.version_override or scenedetect.__version__ + version = msi_version(raw_version) + + advinst = find_advinst() + print(f"Using {advinst}") + if version != raw_version: + print(f"Normalized {raw_version!r} -> {version!r} for AdvancedInstaller") + print(f"Bumping {INSTALLER_AIP.name} to {version}") + + run(advinst, "/SetVersion", version) + run(advinst, "/SetProductCode", "-langid", "1033") + run( + advinst, + "/SetPackageName", + f"PySceneDetect-{version}-win64.msi", + "-buildname", + "DefaultBuild", + ) + + if args.sync_files: + if not DIST_TREE.exists(): + sys.exit( + f"{DIST_TREE} not found. Run " + "`pyinstaller packaging/windows/scenedetect.spec` first." + ) + # /ResetSync errors out if APPDIR isn't already a synced folder + # (true on the first run); /NewSync will fail if it IS synced. So + # try the reset but tolerate failure, then sync. + run(advinst, "/ResetSync", "APPDIR", check=False) + run(advinst, "/NewSync", "APPDIR", str(DIST_TREE)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py new file mode 100644 index 00000000..acfd0776 --- /dev/null +++ b/scripts/generate_manifest.py @@ -0,0 +1,174 @@ +# +# 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/bump_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 new file mode 100644 index 00000000..af5ca1c4 --- /dev/null +++ b/scripts/stage_windows_dist.py @@ -0,0 +1,184 @@ +# +# 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. +# +"""Stage non-pyinstaller assets into dist/scenedetect/. + +Pyinstaller produces only scenedetect.exe + _internal/. This script adds +the rest of what both the AdvancedInstaller MSI and the portable ZIP need: +ffmpeg.exe + its LICENSE, the project LICENSE / README.txt, third-party +licenses, and sphinx-built docs. Mirrors the inline staging steps that +appveyor.yml used to do, so CI and local builds stay in sync. + +Sequence in a release: + + python scripts/pre_release.py + pyinstaller packaging/windows/scenedetect.spec + python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip + python scripts/bump_installer.py --sync-files + AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip + python scripts/generate_manifest.py + +--ffmpeg-dir points at a directory containing ffmpeg.exe and its LICENSE +(e.g. the extracted GyanD codexffmpeg release). If omitted, the script +falls back to extracting ffmpeg.exe from packaging/windows/thirdparty.7z; +LICENSE-FFMPEG is then a stub since the bundled archive doesn't carry it. +""" + +import argparse +import re +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR)) + +import scenedetect # noqa: E402 + +DIST_DIR = REPO_DIR / "dist" +DIST_TREE = DIST_DIR / "scenedetect" +PACKAGING_WIN = REPO_DIR / "packaging" / "windows" +DOCS_DIR = REPO_DIR / "docs" +THIRDPARTY_LICENSES = REPO_DIR / "scenedetect" / "_thirdparty" + + +def msi_version(raw: str) -> str: + 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 + sys.exit("7-Zip not found. Install from https://www.7-zip.org/.") + + +def copy_file(src: Path, dst: Path) -> None: + if not src.exists(): + print(f"WARNING: {src} missing - skipping {dst.name}") + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + print(f" {src.relative_to(REPO_DIR)} -> {dst.relative_to(REPO_DIR)}") + + +def stage_ffmpeg(ffmpeg_dir: Path | None) -> None: + thirdparty = DIST_TREE / "thirdparty" + thirdparty.mkdir(parents=True, exist_ok=True) + if ffmpeg_dir is not None: + print(f"Copying ffmpeg from {ffmpeg_dir}") + copy_file(ffmpeg_dir / "ffmpeg.exe", DIST_TREE / "ffmpeg.exe") + copy_file(ffmpeg_dir / "LICENSE", thirdparty / "LICENSE-FFMPEG") + return + archive = PACKAGING_WIN / "thirdparty.7z" + if not archive.exists(): + sys.exit(f"No --ffmpeg-dir given and {archive} missing.") + sevenz = find_7zip() + staging = DIST_TREE / "_thirdparty_extract" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + print(f"Extracting {archive.name} (bundled fallback)...") + subprocess.run( + [str(sevenz), "x", str(archive), f"-o{staging}", "windows/ffmpeg.exe", "-y"], + check=True, + capture_output=True, + ) + src = staging / "windows" / "ffmpeg.exe" + if src.exists(): + shutil.move(str(src), str(DIST_TREE / "ffmpeg.exe")) + print(" ffmpeg.exe -> dist/scenedetect/ffmpeg.exe") + shutil.rmtree(staging) + # The bundled archive predates LICENSE-FFMPEG; emit a stub pointing at upstream. + stub = thirdparty / "LICENSE-FFMPEG" + stub.write_text( + "FFmpeg is licensed under the LGPL/GPL. See https://ffmpeg.org/legal.html " + "for the canonical license text matching the bundled binary.\n", + encoding="utf-8", + ) + print(f" (stub) -> {stub.relative_to(REPO_DIR)}") + + +def build_docs() -> None: + if not (DOCS_DIR / "Makefile").exists(): + print("WARNING: docs/Makefile missing - skipping docs build") + return + print("Building Sphinx docs (singlehtml)...") + target = DIST_TREE / "docs" + if target.exists(): + shutil.rmtree(target) + subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "singlehtml", str(DOCS_DIR), str(target)], + check=True, + ) + print(" docs -> dist/scenedetect/docs/") + + +def stage_thirdparty_licenses() -> None: + target = DIST_TREE / "thirdparty" + target.mkdir(parents=True, exist_ok=True) + print("Staging third-party licenses...") + for src in sorted(THIRDPARTY_LICENSES.glob("LICENSE-*")): + copy_file(src, target / src.name) + copy_file(PACKAGING_WIN / "LICENSE-PYTHON", target / "LICENSE-PYTHON") + + +def make_portable_zip(version: str) -> None: + zip_path = DIST_DIR / f"PySceneDetect-{version}-portable.zip" + if zip_path.exists(): + zip_path.unlink() + print(f"Creating {zip_path.relative_to(REPO_DIR)}...") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(p for p in DIST_TREE.rglob("*") if p.is_file()): + zf.write(path, path.relative_to(DIST_TREE)) + print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "--ffmpeg-dir", + type=Path, + help="Directory containing ffmpeg.exe and its LICENSE. " + "If omitted, ffmpeg is extracted from packaging/windows/thirdparty.7z.", + ) + parser.add_argument( + "--portable-zip", + action="store_true", + help="Also produce dist/PySceneDetect--portable.zip.", + ) + args = parser.parse_args() + + if not DIST_TREE.exists(): + sys.exit(f"{DIST_TREE} not found. Run pyinstaller first.") + + print(f"Staging into {DIST_TREE.relative_to(REPO_DIR)}") + stage_ffmpeg(args.ffmpeg_dir) + print("Copying root files...") + copy_file(REPO_DIR / "LICENSE", DIST_TREE / "LICENSE") + copy_file(PACKAGING_WIN / "README.txt", DIST_TREE / "README.txt") + stage_thirdparty_licenses() + build_docs() + + if args.portable_zip: + make_portable_zip(msi_version(scenedetect.__version__)) + + +if __name__ == "__main__": + main() From bbcf1873c14db572229ccbdfcaf858cb923d4bf0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 Apr 2026 22:29:16 -0400 Subject: [PATCH 5/9] [dist] Update installer for v0.7 --- appveyor.yml | 11 +- packaging/windows/installer/PySceneDetect.aip | 2830 ++++++++--------- scripts/bump_installer.py | 60 +- 3 files changed, 1444 insertions(+), 1457 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 91c8defd..f22db26e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -40,8 +40,6 @@ install: - python -m pip install --upgrade pip build wheel virtualenv setuptools - python -m pip install .[docs] - python -m pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg - # Checkout build resources and third party software used for testing. - - git checkout refs/remotes/origin/resources -- packaging/ - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' @@ -69,9 +67,14 @@ install: - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.9.1\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" + - cd ../../.. + # Re-sync APPDIR from CI's dist/scenedetect (handles drift between local and + # CI pyinstaller output - new transitive deps, Python patch updates, etc.). + # Does not touch version/GUID fields - those are committed to the .aip on the + # release tag and must stay stable across rebuilds for upgrade-chain integrity. + - python scripts/bump_installer.py --sync-only # Create MSI installer - - AdvancedInstaller.com /build PySceneDetect.aip - - cd ../.. + - AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * PACKAGING BUILD ARTIFACTS * * diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip index 4e692898..0abcfc68 100644 --- a/packaging/windows/installer/PySceneDetect.aip +++ b/packaging/windows/installer/PySceneDetect.aip @@ -1,5 +1,5 @@ - + @@ -23,10 +23,10 @@ - + - + @@ -72,10 +72,13 @@ + + + @@ -83,6 +86,8 @@ + + @@ -110,6 +115,8 @@ + + @@ -117,26 +124,27 @@ + - + - + - + - + - - + + @@ -144,8 +152,8 @@ - - + + @@ -190,12 +198,12 @@ - - - + + + - + @@ -244,7 +252,7 @@ - + @@ -256,173 +264,142 @@ - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + @@ -431,1194 +408,1205 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1634,7 +1622,7 @@ - + @@ -1662,9 +1650,9 @@ - + - + @@ -1706,13 +1694,14 @@ - - - + + + + @@ -1740,13 +1729,17 @@ + + + + @@ -1769,45 +1762,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + @@ -1864,10 +1829,11 @@ + - + - + @@ -1876,20 +1842,22 @@ + + - + - + - + - + - + @@ -1921,12 +1889,6 @@ - - - - - - @@ -1938,7 +1900,6 @@ - @@ -1951,7 +1912,9 @@ - + + + @@ -1959,16 +1922,16 @@ - - - - - - + + + + + + @@ -2020,8 +1983,8 @@ - - + + @@ -2032,10 +1995,13 @@ - + + + + diff --git a/scripts/bump_installer.py b/scripts/bump_installer.py index 18c8f510..be29ee4d 100644 --- a/scripts/bump_installer.py +++ b/scripts/bump_installer.py @@ -13,15 +13,19 @@ Usage: python scripts/bump_installer.py # version bump only - python scripts/bump_installer.py --sync-files # also re-sync APPDIR + python scripts/bump_installer.py --sync-files # bump + re-sync APPDIR + python scripts/bump_installer.py --sync-only # re-sync APPDIR only (CI) python scripts/bump_installer.py --version 0.7.0 # explicit version override The version-bump path rewrites ProductVersion / ProductCode / PackageFileName. -The --sync-files path additionally walks dist/scenedetect/ (pyinstaller output) -and rewrites the project's directory + component + file tables to match, -which is needed when bundled dependencies change. - -Both paths shell out to AdvancedInstaller.com so the .aip's invariants +--sync-files additionally walks dist/scenedetect/ (pyinstaller output) and +rewrites the project's directory + component + file tables to match, which +is needed when bundled dependencies change. --sync-only does the resync +without touching version/identity fields - intended for CI, where the .aip +is already at the release version and we just want the file list to match +CI's pyinstaller output (rather than the developer's local one). + +All paths shell out to AdvancedInstaller.com so the .aip's invariants (line endings, attribute ordering, GUID casing) stay intact. The CLI lives under "C:\\Program Files (x86)\\Caphyon\\Advanced Installer ..\\bin\\x86\\". Override discovery with the ADVINST environment variable. @@ -80,12 +84,30 @@ def run(advinst: Path, *edit_args: str, check: bool = True) -> int: return subprocess.run(cmd, check=check).returncode +def resync_appdir(advinst: Path) -> None: + if not DIST_TREE.exists(): + sys.exit( + f"{DIST_TREE} not found. Run `pyinstaller packaging/windows/scenedetect.spec` first." + ) + # /ResetSync errors out if APPDIR isn't already a synced folder + # (true on the first run); /NewSync will fail if it IS synced. So + # try the reset but tolerate failure, then sync. + run(advinst, "/ResetSync", "APPDIR", check=False) + run(advinst, "/NewSync", "APPDIR", str(DIST_TREE)) + + def main() -> None: parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) - parser.add_argument( + mode = parser.add_mutually_exclusive_group() + mode.add_argument( "--sync-files", action="store_true", - help="Re-sync APPDIR from dist/scenedetect/ (run pyinstaller first).", + help="Bump version/GUIDs AND re-sync APPDIR from dist/scenedetect/.", + ) + mode.add_argument( + "--sync-only", + action="store_true", + help="Re-sync APPDIR only; leave version/GUID fields untouched (CI use).", ) parser.add_argument( "--version", @@ -94,11 +116,16 @@ def main() -> None: ) args = parser.parse_args() - raw_version = args.version_override or scenedetect.__version__ - version = msi_version(raw_version) - advinst = find_advinst() print(f"Using {advinst}") + + if args.sync_only: + print(f"Re-syncing APPDIR in {INSTALLER_AIP.name}") + resync_appdir(advinst) + return + + raw_version = args.version_override or scenedetect.__version__ + version = msi_version(raw_version) if version != raw_version: print(f"Normalized {raw_version!r} -> {version!r} for AdvancedInstaller") print(f"Bumping {INSTALLER_AIP.name} to {version}") @@ -114,16 +141,7 @@ def main() -> None: ) if args.sync_files: - if not DIST_TREE.exists(): - sys.exit( - f"{DIST_TREE} not found. Run " - "`pyinstaller packaging/windows/scenedetect.spec` first." - ) - # /ResetSync errors out if APPDIR isn't already a synced folder - # (true on the first run); /NewSync will fail if it IS synced. So - # try the reset but tolerate failure, then sync. - run(advinst, "/ResetSync", "APPDIR", check=False) - run(advinst, "/NewSync", "APPDIR", str(DIST_TREE)) + resync_appdir(advinst) if __name__ == "__main__": From 217de5d4988055e3f5beaa0df871b19c71b2db63 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 27 Apr 2026 21:08:18 -0400 Subject: [PATCH 6/9] [build] Allow building development .msi artifacts --- RELEASE-PLAN.md | 1 + appveyor.yml | 20 ++++++++++++++-- scripts/bump_installer.py | 45 ++++++++++++++++++++++------------- scripts/pre_release.py | 17 ++++++++++--- scripts/stage_windows_dist.py | 20 ++++++++++++++-- 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 269275a9..ab16b6ab 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -43,6 +43,7 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] `python scripts/pre_release.py --release` passes (enforces `.aip` ↔ `__version__` parity, writes `packaging/windows/.version_info`). - [ ] `pyinstaller packaging/windows/scenedetect.spec` produces a working `scenedetect.exe` - run it against a sample video. - [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). +- [ ] `python scripts/bump_installer.py --sync-files` and commit the .aip diff (refreshes the APPDIR baseline so CI's per-build `--sync-only` diff stays small). - [ ] Build the MSI via Advanced Installer (`packaging/windows/installer/PySceneDetect.aip`); install into a clean Windows VM and run the CLI. - [ ] After both `pyinstaller` and the MSI build are done (and the portable `.zip` is staged at `dist/PySceneDetect-X.Y.Z-portable.zip`), run `python scripts/generate_manifest.py` to produce `dist/PySceneDetect-X.Y.Z.manifest.json` (per-file SHA256 audit of every artifact) and `dist/SHA256SUMS` (flat `sha256sum -c` compatible). Both are attached to the GitHub release in step 7. diff --git a/appveyor.yml b/appveyor.yml index f22db26e..1e84207b 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -64,7 +64,11 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.9.1\\bin\\x86' + # Resolve the installed Advanced Installer bin path dynamically - the upstream + # MSI is unversioned so the directory name (Advanced Installer X.Y.Z) drifts. + - ps: $aiBin = (Get-ChildItem 'C:\Program Files (x86)\Caphyon\Advanced Installer*\bin\x86' | Sort-Object FullName -Descending | Select-Object -First 1).FullName; Add-Content $env:APPVEYOR_BUILD_FOLDER\ai_path.txt $aiBin + - set /p AI_BIN=<%APPVEYOR_BUILD_FOLDER%\ai_path.txt + - 'SET PATH=%PATH%;%AI_BIN%' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" - cd ../../.. @@ -72,7 +76,14 @@ install: # CI pyinstaller output - new transitive deps, Python patch updates, etc.). # Does not touch version/GUID fields - those are committed to the .aip on the # release tag and must stay stable across rebuilds for upgrade-chain integrity. - - python scripts/bump_installer.py --sync-only + # On non-tag builds, also pass --dev so the MSI is named PySceneDetect-{ver}-dev-win64.msi + # (keeps dev artifacts distinguishable from signed releases). + - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/bump_installer.py --sync-only) else (python scripts/bump_installer.py --sync-only --dev) + # Snapshot the post-sync .aip and the actual payload tree as build artifacts. + # The committed .aip is a baseline; CI adapts it to its own pyinstaller output + # and we never write back to git, so these snapshots are the authoritative + # record of what each MSI was built from (for audit / release forensics). + - copy packaging\windows\installer\PySceneDetect.aip dist\PySceneDetect.aip # Create MSI installer - AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip @@ -112,3 +123,8 @@ artifacts: # 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 + name: PySceneDetect-build-manifest-payload diff --git a/scripts/bump_installer.py b/scripts/bump_installer.py index be29ee4d..2f9af18d 100644 --- a/scripts/bump_installer.py +++ b/scripts/bump_installer.py @@ -12,23 +12,18 @@ """Bump the AdvancedInstaller .aip project for a release. Usage: - python scripts/bump_installer.py # version bump only - python scripts/bump_installer.py --sync-files # bump + re-sync APPDIR - python scripts/bump_installer.py --sync-only # re-sync APPDIR only (CI) - python scripts/bump_installer.py --version 0.7.0 # explicit version override - -The version-bump path rewrites ProductVersion / ProductCode / PackageFileName. ---sync-files additionally walks dist/scenedetect/ (pyinstaller output) and -rewrites the project's directory + component + file tables to match, which -is needed when bundled dependencies change. --sync-only does the resync -without touching version/identity fields - intended for CI, where the .aip -is already at the release version and we just want the file list to match -CI's pyinstaller output (rather than the developer's local one). - -All paths shell out to AdvancedInstaller.com so the .aip's invariants -(line endings, attribute ordering, GUID casing) stay intact. The CLI lives -under "C:\\Program Files (x86)\\Caphyon\\Advanced Installer ..\\bin\\x86\\". -Override discovery with the ADVINST environment variable. + python scripts/bump_installer.py # version bump only + python scripts/bump_installer.py --sync-files # bump + re-sync APPDIR + python scripts/bump_installer.py --sync-only # re-sync APPDIR only (CI) + python scripts/bump_installer.py --sync-only --dev # CI dev build (renames MSI) + python scripts/bump_installer.py --version 0.7.0 # explicit version override + +The committed .aip is a baseline; CI's --sync-only adapts it per build and is +never written back to git. Refresh locally with --sync-files before each release. + +All paths shell out to AdvancedInstaller.com to preserve .aip invariants +(line endings, attribute ordering, GUID casing). Override CLI discovery with +the ADVINST environment variable. """ import argparse @@ -109,6 +104,14 @@ def main() -> None: action="store_true", help="Re-sync APPDIR only; leave version/GUID fields untouched (CI use).", ) + parser.add_argument( + "--dev", + action="store_true", + help=( + "Rename the MSI to PySceneDetect-{ver}-dev-win64.msi so dev-build artifacts " + "are distinguishable from release artifacts. Only valid with --sync-only." + ), + ) parser.add_argument( "--version", dest="version_override", @@ -116,12 +119,20 @@ def main() -> None: ) args = parser.parse_args() + if args.dev and not args.sync_only: + sys.exit("--dev is only valid in combination with --sync-only.") + advinst = find_advinst() print(f"Using {advinst}") if args.sync_only: print(f"Re-syncing APPDIR in {INSTALLER_AIP.name}") resync_appdir(advinst) + if args.dev: + version = msi_version(args.version_override or scenedetect.__version__) + dev_name = f"PySceneDetect-{version}-dev-win64.msi" + print(f"Renaming MSI package to {dev_name} (dev build)") + run(advinst, "/SetPackageName", dev_name, "-buildname", "DefaultBuild") return raw_version = args.version_override or scenedetect.__version__ diff --git a/scripts/pre_release.py b/scripts/pre_release.py index 2cdb32a8..1335fe09 100644 --- a/scripts/pre_release.py +++ b/scripts/pre_release.py @@ -18,8 +18,12 @@ import sys from pathlib import Path -REPO_DIR = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = Path(__file__).resolve().parent +REPO_DIR = SCRIPTS_DIR.parent sys.path.insert(0, str(REPO_DIR)) +sys.path.insert(0, str(SCRIPTS_DIR)) + +from bump_installer import msi_version # noqa: E402 import scenedetect # noqa: E402 @@ -34,8 +38,15 @@ if run_version_check: installer_aip = INSTALLER_AIP.read_text() - aip_version = f'' - assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." + # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ + # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same + # function bump_installer.py uses to write the .aip so the comparison is apples-to-apples. + expected = msi_version(VERSION) + aip_row = f'' + assert aip_row in installer_aip, ( + f"Installer ProductVersion does not match normalized {VERSION!r} ({expected!r}). " + f"Run `python scripts/bump_installer.py` to refresh the .aip." + ) with VERSION_INFO.open("wb") as f: v = VERSION.split(".") diff --git a/scripts/stage_windows_dist.py b/scripts/stage_windows_dist.py index af5ca1c4..469b567a 100644 --- a/scripts/stage_windows_dist.py +++ b/scripts/stage_windows_dist.py @@ -69,13 +69,22 @@ def find_7zip() -> Path: sys.exit("7-Zip not found. Install from https://www.7-zip.org/.") +def _rel(p: Path) -> str: + # Display paths relative to the repo when possible, else fall back to the + # absolute path (e.g. --ffmpeg-dir pointing outside the repo on CI). + try: + return str(p.relative_to(REPO_DIR)) + except ValueError: + return str(p) + + def copy_file(src: Path, dst: Path) -> None: if not src.exists(): print(f"WARNING: {src} missing - skipping {dst.name}") return dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) - print(f" {src.relative_to(REPO_DIR)} -> {dst.relative_to(REPO_DIR)}") + print(f" {_rel(src)} -> {_rel(dst)}") def stage_ffmpeg(ffmpeg_dir: Path | None) -> None: @@ -141,13 +150,20 @@ 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" if zip_path.exists(): zip_path.unlink() print(f"Creating {zip_path.relative_to(REPO_DIR)}...") + files = sorted(p for p in DIST_TREE.rglob("*") if p.is_file()) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for path in sorted(p for p in DIST_TREE.rglob("*") if p.is_file()): + for path in files: zf.write(path, path.relative_to(DIST_TREE)) print(f" {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + manifest_path.write_text( + "\n".join(str(p.relative_to(DIST_TREE)) for p in files) + "\n", + encoding="utf-8", + ) + print(f" manifest -> {manifest_path.relative_to(REPO_DIR)}") def main() -> None: From 5d5bcac81367a708c8c9f476ec8debc3fae93b0b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 28 Apr 2026 20:17:06 -0400 Subject: [PATCH 7/9] [dist] Update Windows dependencies for 0.7 release --- .github/workflows/build-windows.yml | 9 ++++--- .github/workflows/publish-pypi.yml | 2 +- RELEASE-PLAN.md | 6 ++--- appveyor.yml | 6 ++++- packaging/windows/pyi_rth_scenedetect.py | 28 ++++++++++++++++++++ packaging/windows/requirements.txt | 14 +++++----- packaging/windows/scenedetect.spec | 33 +++++++++++++++++++++--- tests/release/synthetic.py | 2 +- tests/release/test_backends.py | 2 +- tests/release/test_long_video.py | 2 +- tests/release/test_validation.py | 4 +-- tests/test_detectors.py | 2 +- tests/test_output.py | 6 ++--- tests/test_vfr.py | 2 +- website/pages/changelog.md | 14 ++++++++++ website/pages/download.md | 2 +- website/pages/faq.md | 2 +- 17 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 packaging/windows/pyi_rth_scenedetect.py diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index f03f7881..3fbe4fbe 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -36,8 +36,7 @@ jobs: python-version: ["3.13"] env: - ffmpeg-version: "7.1" - IMAGEIO_FFMPEG_EXE: "" + ffmpeg-version: "8.1" steps: - uses: actions/checkout@v5 @@ -70,7 +69,11 @@ jobs: shell: bash run: | 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r - echo "IMAGEIO_FFMPEG_EXE=`realpath ffmpeg.exe`" >> "$GITHUB_ENV" + export PATH="$(pwd):$PATH" + # moviepy.config resolves ffmpeg via imageio_ffmpeg at import time; `--no-binary` + # strips the bundled binary, so point at the GyanD ffmpeg we just extracted + # for both pytest and the subsequent pyinstaller step. + echo "IMAGEIO_FFMPEG_EXE=$(realpath ffmpeg.exe)" >> "$GITHUB_ENV" python -m pytest -vv - name: Build PySceneDetect diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 607940c2..98d31c43 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -60,7 +60,7 @@ jobs: core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); allSuccess = false; } else { - console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); + console.log(`[OK] Workflow "${workflowName}" succeeded for tag ${tag}.`); } } diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index ab16b6ab..bdf31c68 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -33,14 +33,14 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). - [ ] `ruff check scenedetect/ tests/` and `ruff format --check scenedetect/ tests/` pass. -- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` - all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS × 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. +- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` - all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS x 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. - [ ] `resources` branch has the artifacts the release tests need (goldens under `tests/resources/goldens/`, `tests/resources/stress_15min.mp4`). Re-push if any golden was regenerated. - [ ] Manual smoke: fresh venv, `pip install .` (pulls opencv-python automatically) then `pip install .[pyav]`; run `scenedetect -i