Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 signed-exe swap and hashing must run locally):
- Create `dist/signed/` and copy in both `scenedetect-signed.zip` (from SignPath) and `PySceneDetect-X.Y.Z-win64.zip` (from the AppVeyor `PySceneDetect-win64` artifact).
- Run `python scripts/finalize_windows_dist.py`. This swaps the signed `scenedetect.exe` into the portable `.zip`, repacks it with 7-Zip, copies out the signed `.msi`, 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
8 changes: 4 additions & 4 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,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