|
| 1 | +# |
| 2 | +# PySceneDetect: Python-Based Video Scene Detector |
| 3 | +# ------------------------------------------------------------------- |
| 4 | +# [ Site: https://scenedetect.com ] |
| 5 | +# [ Docs: https://scenedetect.com/docs/ ] |
| 6 | +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] |
| 7 | +# |
| 8 | +# Copyright (C) 2026 Brandon Castellano <http://www.bcastell.com>. |
| 9 | +# PySceneDetect is licensed under the BSD 3-Clause License; see the |
| 10 | +# included LICENSE file, or visit one of the above pages for details. |
| 11 | +# |
| 12 | +"""Generate a SHA256 audit manifest for the Windows release artifacts. |
| 13 | +
|
| 14 | +Walks the pyinstaller output tree, the built MSI, and the portable ZIP |
| 15 | +(if present), hashes every file, and writes: |
| 16 | +
|
| 17 | + dist/PySceneDetect-X.Y.Z.manifest.json - structured per-file manifest |
| 18 | + dist/SHA256SUMS - flat sha256sum -c compatible |
| 19 | +
|
| 20 | +Run after both `pyinstaller packaging/windows/scenedetect.spec` and the |
| 21 | +AdvancedInstaller MSI build have completed. Attach both outputs to the |
| 22 | +GitHub release so users can verify what they downloaded. |
| 23 | +""" |
| 24 | + |
| 25 | +import argparse |
| 26 | +import hashlib |
| 27 | +import json |
| 28 | +import re |
| 29 | +import sys |
| 30 | +import zipfile |
| 31 | +from datetime import datetime, timezone |
| 32 | +from pathlib import Path |
| 33 | + |
| 34 | +REPO_DIR = Path(__file__).resolve().parent.parent |
| 35 | +sys.path.insert(0, str(REPO_DIR)) |
| 36 | + |
| 37 | +import scenedetect # noqa: E402 |
| 38 | + |
| 39 | + |
| 40 | +def msi_version(raw: str) -> str: |
| 41 | + # Mirror scripts/bump_installer.py - the artifact filename uses the |
| 42 | + # normalized X.Y.Z form, not the Python __version__ string. |
| 43 | + parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] |
| 44 | + while len(parts) < 3: |
| 45 | + parts.append("0") |
| 46 | + return ".".join(parts[:4]) |
| 47 | + |
| 48 | + |
| 49 | +VERSION = msi_version(scenedetect.__version__) |
| 50 | +DIST_DIR = REPO_DIR / "dist" |
| 51 | +PYINSTALLER_TREE = DIST_DIR / "scenedetect" |
| 52 | +MSI_PATH = REPO_DIR / "packaging" / "windows" / "installer" / f"PySceneDetect-{VERSION}-win64.msi" |
| 53 | +PORTABLE_ZIP = DIST_DIR / f"PySceneDetect-{VERSION}-portable.zip" |
| 54 | + |
| 55 | +CHUNK = 1 << 20 # 1 MiB |
| 56 | + |
| 57 | + |
| 58 | +def sha256_file(path: Path) -> str: |
| 59 | + h = hashlib.sha256() |
| 60 | + with path.open("rb") as f: |
| 61 | + for block in iter(lambda: f.read(CHUNK), b""): |
| 62 | + h.update(block) |
| 63 | + return h.hexdigest() |
| 64 | + |
| 65 | + |
| 66 | +def hash_tree(root: Path) -> list[dict]: |
| 67 | + entries = [] |
| 68 | + for path in sorted(p for p in root.rglob("*") if p.is_file()): |
| 69 | + entries.append( |
| 70 | + { |
| 71 | + "path": path.relative_to(root).as_posix(), |
| 72 | + "size": path.stat().st_size, |
| 73 | + "sha256": sha256_file(path), |
| 74 | + } |
| 75 | + ) |
| 76 | + return entries |
| 77 | + |
| 78 | + |
| 79 | +def hash_zip_contents(zip_path: Path) -> list[dict]: |
| 80 | + entries = [] |
| 81 | + with zipfile.ZipFile(zip_path) as zf: |
| 82 | + for info in sorted(zf.infolist(), key=lambda i: i.filename): |
| 83 | + if info.is_dir(): |
| 84 | + continue |
| 85 | + h = hashlib.sha256() |
| 86 | + with zf.open(info) as f: |
| 87 | + for block in iter(lambda: f.read(CHUNK), b""): |
| 88 | + h.update(block) |
| 89 | + entries.append( |
| 90 | + { |
| 91 | + "path": info.filename, |
| 92 | + "size": info.file_size, |
| 93 | + "sha256": h.hexdigest(), |
| 94 | + } |
| 95 | + ) |
| 96 | + return entries |
| 97 | + |
| 98 | + |
| 99 | +def main() -> None: |
| 100 | + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) |
| 101 | + parser.add_argument( |
| 102 | + "--out", |
| 103 | + type=Path, |
| 104 | + default=DIST_DIR / f"PySceneDetect-{VERSION}.manifest.json", |
| 105 | + help="Path to the JSON manifest output.", |
| 106 | + ) |
| 107 | + parser.add_argument( |
| 108 | + "--sums", |
| 109 | + type=Path, |
| 110 | + default=DIST_DIR / "SHA256SUMS", |
| 111 | + help="Path to the flat sha256sum-compatible output.", |
| 112 | + ) |
| 113 | + args = parser.parse_args() |
| 114 | + |
| 115 | + bundles: dict[str, dict] = {} |
| 116 | + top_level: list[tuple[str, str]] = [] # (sha256, relpath) for SHA256SUMS |
| 117 | + |
| 118 | + if PYINSTALLER_TREE.is_dir(): |
| 119 | + print(f"Hashing pyinstaller tree: {PYINSTALLER_TREE}") |
| 120 | + bundles["pyinstaller_tree"] = { |
| 121 | + "path": PYINSTALLER_TREE.relative_to(REPO_DIR).as_posix(), |
| 122 | + "files": hash_tree(PYINSTALLER_TREE), |
| 123 | + } |
| 124 | + else: |
| 125 | + print(f"WARNING: {PYINSTALLER_TREE} missing - skipping.") |
| 126 | + |
| 127 | + if MSI_PATH.is_file(): |
| 128 | + print(f"Hashing MSI: {MSI_PATH}") |
| 129 | + digest = sha256_file(MSI_PATH) |
| 130 | + bundles["msi"] = { |
| 131 | + "path": MSI_PATH.relative_to(REPO_DIR).as_posix(), |
| 132 | + "size": MSI_PATH.stat().st_size, |
| 133 | + "sha256": digest, |
| 134 | + } |
| 135 | + top_level.append((digest, MSI_PATH.name)) |
| 136 | + else: |
| 137 | + print(f"WARNING: {MSI_PATH} missing - skipping.") |
| 138 | + |
| 139 | + if PORTABLE_ZIP.is_file(): |
| 140 | + print(f"Hashing portable zip: {PORTABLE_ZIP}") |
| 141 | + digest = sha256_file(PORTABLE_ZIP) |
| 142 | + bundles["portable_zip"] = { |
| 143 | + "path": PORTABLE_ZIP.relative_to(REPO_DIR).as_posix(), |
| 144 | + "size": PORTABLE_ZIP.stat().st_size, |
| 145 | + "sha256": digest, |
| 146 | + "contents": hash_zip_contents(PORTABLE_ZIP), |
| 147 | + } |
| 148 | + top_level.append((digest, PORTABLE_ZIP.name)) |
| 149 | + else: |
| 150 | + print(f"WARNING: {PORTABLE_ZIP} missing - skipping.") |
| 151 | + |
| 152 | + if not bundles: |
| 153 | + sys.exit("No artifacts found to hash.") |
| 154 | + |
| 155 | + manifest = { |
| 156 | + "version": VERSION, |
| 157 | + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| 158 | + "bundles": bundles, |
| 159 | + } |
| 160 | + |
| 161 | + args.out.parent.mkdir(parents=True, exist_ok=True) |
| 162 | + args.out.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") |
| 163 | + print(f"Wrote {args.out}") |
| 164 | + |
| 165 | + if top_level: |
| 166 | + args.sums.write_text( |
| 167 | + "".join(f"{sha} {name}\n" for sha, name in top_level), |
| 168 | + encoding="utf-8", |
| 169 | + ) |
| 170 | + print(f"Wrote {args.sums}") |
| 171 | + |
| 172 | + |
| 173 | +if __name__ == "__main__": |
| 174 | + main() |
0 commit comments