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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -117,7 +117,7 @@ jobs:

- uses: actions/download-artifact@v7
with:
name: PySceneDetect-win64_portable
name: PySceneDetect-win64
path: build

- name: Test
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 5 additions & 3 deletions RELEASE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/LATEST_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.7
0.7
854 changes: 513 additions & 341 deletions packaging/windows/installer/PySceneDetect.aip

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scenedetect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 6 additions & 6 deletions scenedetect/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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__":
Expand Down
3 changes: 2 additions & 1 deletion scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions scenedetect/_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -543,15 +543,15 @@ 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"
" -f/--frame-rate option, or try re-encoding the file.",
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(
Expand All @@ -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"
Expand Down
127 changes: 80 additions & 47 deletions scenedetect/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
##
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading