"""Construit le bundle autonome dans un environnement isolé jetable. Pourquoi un environnement dédié à la construction, distinct de celui d'exécution : PyInstaller embarque ce qu'il trouve. Construire depuis l'installation Python courante ferait entrer dans le bundle tout ce qui traîne dans site-packages, sans rapport avec l'outil. Le profil normal suit les dépendances courantes. Le profil ``--win7`` est une cible legacy séparée et reproductible : CPython 3.8.10 x64, dépendances figées et roue blinkpy 0.25.9 dont seules les métadonnées sont rétroportées. python build.py construit le bundle normal python build.py --propre le reconstruit depuis zéro python build.py --win7 --propre construit le candidat Windows 7 """ from __future__ import annotations # Python 3.8 (build Windows 7) : les annotations "X | None" ne s'évaluent qu'à l'écriture des chaînes, jamais à l'exécution. import argparse import hashlib import inspect import os import shutil import struct import subprocess import sys from pathlib import Path BASE_DIR = Path(__file__).resolve().parent SPEC = BASE_DIR / "blink2video.spec" # Profil ordinaire : verrouillé et à empreintes (requirements-build.in -> .txt # via uv pip compile, voir l'en-tête de requirements.in), même esprit que # WIN7_REQUIREMENTS ci-dessous. blink_engine.py # rattrape une API privée de blinkpy (monkey-patch sur BlinkLiveStream) ; # une version flottante pourrait la casser en silence à la prochaine release. REQUIREMENTS = BASE_DIR / "requirements-build.txt" # Profil Windows 7 : jamais mélangé au venv ni aux sorties officielles. WIN7_PYTHON = (3, 8, 10) WIN7_VENV = BASE_DIR / "build_venv_win7" WIN7_WORK = BASE_DIR / "build-win7" WIN7_DIST = BASE_DIR / "dist-win7" WIN7_REQUIREMENTS = BASE_DIR / "requirements-win7.txt" # Compilation complète de secours, quand celle fournie par imageio-ffmpeg ne # sait pas incruster de texte. C'est le cas sous Linux, où elle est produite # sans libfreetype. Variante « gpl » : c'est celle qui embarque libfreetype. # # Version et empreinte FIGÉES (issue #49) : l'ancienne adresse « latest » # changeait à chaque nuit, donc deux releases Linux construites à un jour # d'écart pouvaient embarquer deux FFmpeg différents, sans qu'une ligne de code # ait bougé, et un exécutable téléchargé à la construction était livré sans # vérification. On vise la branche stable 8.1 (pas la branche de développement) # d'une construction datée, dont BtbN publie la somme dans checksums.sha256 ; # l'empreinte ci-dessous a été recalculée à la main sur l'archive téléchargée. # Pour changer de version : prendre une autre release « autobuild-AAAA-MM-JJ-… » # de https://github.com/BtbN/FFmpeg-Builds/releases, recopier le nom de # l'archive 8.1 et sa ligne de checksums.sha256, puis rejouer la validation des # clips USB (valid_mp4_complet) sur de vrais clips avant de publier. FFMPEG_SECOURS_TAG = "autobuild-2026-09-30-13-08" FFMPEG_SECOURS_ARCHIVE = "ffmpeg-n8.1.3-9-g29e619e767-linux64-gpl-8.1.tar.xz" FFMPEG_SECOURS_SHA256 = "97ce978979194b5cf7e06a5e68020dbdaa7a4f3294c5452b6a1bc347100dbb79" FFMPEG_SECOURS = ( "https://github.com/BtbN/FFmpeg-Builds/releases/download/" f"{FFMPEG_SECOURS_TAG}/{FFMPEG_SECOURS_ARCHIVE}" ) def empreinte_sha256(chemin: Path) -> str: """SHA-256 d'un fichier, lu par blocs : l'archive pèse environ 150 Mo.""" somme = hashlib.sha256() with open(chemin, "rb") as fichier: for bloc in iter(lambda: fichier.read(1 << 20), b""): somme.update(bloc) return somme.hexdigest() def verifier_empreinte(chemin: Path, attendue: str) -> bool: """Vrai si le fichier a bien l'empreinte attendue (casse indifférente).""" return empreinte_sha256(chemin) == attendue.strip().lower() def _chemins(win7: bool) -> tuple: if win7: return WIN7_VENV, WIN7_WORK, WIN7_DIST return BASE_DIR / "build_venv", BASE_DIR / "build", BASE_DIR / "dist" def _python(venv: Path) -> Path: return venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") def verifier_interpreteur_win7() -> None: """Refuse un runtime dont les DLL ne sont pas celles garanties pour Win7.""" erreurs = [] if sys.platform != "win32": erreurs.append("la construction doit tourner sous Windows") if sys.version_info[:3] != WIN7_PYTHON: erreurs.append( "CPython 3.8.10 exact est requis " f"(reçu {sys.version_info.major}.{sys.version_info.minor}." f"{sys.version_info.micro})" ) if struct.calcsize("P") != 8: erreurs.append("un interpréteur x86-64 est requis") if sys.implementation.name != "cpython": erreurs.append("l'interpréteur doit être CPython") if erreurs: raise SystemExit("Build Windows 7 refusé : " + "; ".join(erreurs) + ".") def verifier_python_win7(python: Path) -> None: """Refuse aussi un ancien venv Win7 créé avec un autre interpréteur.""" programme = ( "import struct,sys; " "print('%d.%d.%d|%d|%s' % " "(sys.version_info.major, sys.version_info.minor, sys.version_info.micro, " "struct.calcsize('P') * 8, sys.implementation.name))" ) attendu = ".".join(str(partie) for partie in WIN7_PYTHON) + "|64|cpython" try: resultat = subprocess.run( [str(python), "-c", programme], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30, check=False, ) except OSError as erreur: raise SystemExit( f"Build Windows 7 refusé : environnement illisible ({erreur}). " "Relancer avec --propre." ) from erreur obtenu = (resultat.stdout or "").strip() if resultat.returncode != 0 or obtenu != attendu: detail = obtenu or (resultat.stderr or "").strip() or "identité inconnue" raise SystemExit( f"Build Windows 7 refusé : l'environnement utilise {detail}, " f"attendu {attendu}. Relancer avec --propre." ) def ffmpeg_utilisable(python: Path, travail: Path) -> tuple[str, str | None]: """Choisit le ffmpeg à embarquer, en exigeant qu'il sache écrire du texte. Renvoie aussi un ffprobe si l'occasion se présente (secours Linux seulement, voir plus bas) : sans lui, merge_daily.valid_mp4_complet() retombe sur une copie de flux ffmpeg avec -xerror pour valider un téléchargement, bien plus stricte qu'un simple comptage de paquets ffprobe et prompte à refuser un fichier pourtant exploitable (signalé sur l'issue GitHub #10, uniquement sous Linux).""" def sait_ecrire(binaire: str) -> bool: try: sortie = subprocess.run( [binaire, "-hide_banner", "-filters"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, errors="replace", timeout=60, check=False, ).stdout except Exception: return False return "drawtext" in (sortie or "") try: resultat = subprocess.run( [str(python), "-c", "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())"], stdout=subprocess.PIPE, text=True, check=True, ).stdout.strip() except Exception: resultat = "" if resultat and sait_ecrire(resultat): # imageio_ffmpeg ne fournit jamais de ffprobe, sur aucune plateforme. return resultat, None if sys.platform != "linux": raise SystemExit( "Aucun ffmpeg capable d'incruster du texte n'a été trouvé, et aucune " "solution de secours n'est prévue pour cette plateforme." ) print("\n=== ffmpeg fourni sans drawtext, récupération d'une compilation complète") import tarfile import urllib.request archive = travail / "ffmpeg-linux.tar.xz" archive.parent.mkdir(parents=True, exist_ok=True) # Une archive déjà présente (cache de travail) est revérifiée : une copie # d'une autre version, ou tronquée, ne doit jamais être réutilisée. if archive.exists() and not verifier_empreinte(archive, FFMPEG_SECOURS_SHA256): archive.unlink() if not archive.exists(): urllib.request.urlretrieve(FFMPEG_SECOURS, archive) if not verifier_empreinte(archive, FFMPEG_SECOURS_SHA256): obtenue = empreinte_sha256(archive) archive.unlink() raise SystemExit( "L'archive FFmpeg téléchargée n'a pas l'empreinte attendue " f"(attendue {FFMPEG_SECOURS_SHA256}, obtenue {obtenue}) : " f"{FFMPEG_SECOURS}. Construction arrêtée plutôt que d'embarquer " "un exécutable non vérifié." ) with tarfile.open(archive) as fichier: membres = fichier.getmembers() membre = next(m for m in membres if m.name.endswith("/bin/ffmpeg")) membre.name = "ffmpeg" fichier.extract(membre, archive.parent) # Le même tar contient déjà ffprobe (BtbN livre les deux ensemble) : # au passage, sans repasser par le réseau. membre_probe = next( (m for m in membres if m.name.endswith("/bin/ffprobe")), None) if membre_probe is not None: membre_probe.name = "ffprobe" fichier.extract(membre_probe, archive.parent) binaire = archive.parent / "ffmpeg" binaire.chmod(0o755) if not sait_ecrire(str(binaire)): raise SystemExit("La compilation de secours ne sait pas non plus écrire du texte.") sonde = archive.parent / "ffprobe" if sonde.is_file(): sonde.chmod(0o755) return str(binaire), (str(sonde) if sonde.is_file() else None) def executer(commande: list, titre: str) -> None: print(f"\n=== {titre}") resultat = subprocess.run(commande, cwd=str(BASE_DIR), check=False) if resultat.returncode != 0: raise SystemExit(f"Échec : {titre}") def installer_win7(python: Path, travail: Path) -> None: """Installe le verrou Python 3.8 et la roue blinkpy reconditionnée.""" from build_blinkpy_win7 import WIN7_WHEEL executer( [str(python), "-m", "pip", "install", "--quiet", "--upgrade", "pip==25.0.1"], "installation de pip compatible Python 3.8", ) executer( [str(python), "-m", "pip", "install", "--quiet", "--requirement", str(WIN7_REQUIREMENTS)], "installation du verrou Windows 7", ) roues = travail / "wheels" executer( [str(python), str(BASE_DIR / "build_blinkpy_win7.py"), str(roues)], "rétroportage des métadonnées blinkpy 0.25.9", ) executer( [str(python), "-m", "pip", "install", "--quiet", "--force-reinstall", "--no-deps", str(roues / WIN7_WHEEL)], "installation de blinkpy pour Windows 7", ) executer([str(python), "-m", "pip", "check"], "cohérence des dépendances Windows 7") def verifier_blinkpy_win7(python: Path) -> None: """Prouve que pip n'a ni rétrogradé blinkpy ni perdu son API moderne.""" from build_blinkpy_win7 import WIN7_VERSION programme = """ import compileall import inspect from importlib.metadata import distribution, version from blinkpy.auth import Auth, BlinkTwoFARequiredError from blinkpy.blinkpy import Blink from blinkpy.camera import BlinkCamera from blinkpy.livestream import BlinkLiveStream attendue = %r obtenue = version("blinkpy") assert obtenue == attendue, (obtenue, attendue) assert inspect.iscoroutinefunction(Blink.start) for classe, noms in ( (Blink, ("send_2fa_code", "get_videos_metadata")), (BlinkCamera, ("init_livestream",)), (BlinkLiveStream, ("start", "feed", "recv", "stop")), ): for nom in noms: assert hasattr(classe, nom), "%%s.%%s absent" %% (classe.__name__, nom) assert "callback" in inspect.signature(Auth.__init__).parameters # Les médias cloud passent par Auth.query avec leur propre délai (#139). assert "timeout" in inspect.signature(Auth.query).parameters racine = distribution("blinkpy").locate_file("blinkpy") assert compileall.compile_dir(str(racine), quiet=1) print("blinkpy %%s : API moderne compilée sous Python 3.8" %% obtenue) """ % WIN7_VERSION executer([str(python), "-c", inspect.cleandoc(programme)], "validation du rétroportage blinkpy") def verifier_webrtc_win7(python: Path) -> None: """Ne laisse pas un import natif manquant masquer WebRTC derrière MSE.""" programme = """ from importlib.metadata import version import blink_webrtc from aiortc import RTCCertificate from av import CodecContext import pylibsrtp attendues = { "aiortc": "1.9.0", "av": "12.3.0", "cryptography": "42.0.8", "pyOpenSSL": "24.1.0", "pylibsrtp": "0.10.0", } for paquet, attendue in attendues.items(): obtenue = version(paquet) assert obtenue == attendue, (paquet, obtenue, attendue) assert blink_webrtc.DISPONIBLE, "WebRTC absent du profil Win7" assert RTCCertificate.generateCertificate().getFingerprints() assert CodecContext.create("h264", "r") is not None print("WebRTC Win7 : imports natifs, certificat DTLS et decodeur H.264 OK") """ executer([str(python), "-c", inspect.cleandoc(programme)], "validation des dépendances WebRTC Windows 7") def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--propre", action="store_true", help="supprimer l'environnement et les sorties de ce profil avant de commencer", ) parser.add_argument( "--win7", action="store_true", help="construire l'édition legacy Windows 7 (CPython 3.8.10 x64)", ) args = parser.parse_args() if args.win7: verifier_interpreteur_win7() venv, travail, dist = _chemins(args.win7) python = _python(venv) sortie = dist / "blink2video" if args.propre: for dossier in (venv, travail, dist): if dossier.exists(): print(f"Suppression de {dossier.name}...") shutil.rmtree(dossier, ignore_errors=True) if not python.exists(): executer([sys.executable, "-m", "venv", str(venv)], "création de l'environnement de construction") if args.win7: verifier_python_win7(python) installer_win7(python, travail) verifier_blinkpy_win7(python) verifier_webrtc_win7(python) else: executer([str(python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"], "mise à jour de pip") executer([str(python), "-m", "pip", "install", "--quiet", "-r", str(REQUIREMENTS)], "installation des dépendances") ffmpeg, ffprobe = ffmpeg_utilisable(python, travail) print(f"\nffmpeg embarqué : {ffmpeg}") os.environ["BLINK_FFMPEG"] = ffmpeg os.environ.pop("BLINK_FFPROBE", None) if ffprobe: print(f"ffprobe embarqué : {ffprobe}") os.environ["BLINK_FFPROBE"] = ffprobe os.environ.pop("BLINK_BUILD_TARGET", None) if args.win7: os.environ["BLINK_BUILD_TARGET"] = "windows7" commande = [ str(python), "-m", "PyInstaller", "--noconfirm", "--clean", "--distpath", str(dist), "--workpath", str(travail), str(SPEC), ] executer(commande, "construction du bundle") executable = sortie / ("blink2video.exe" if sys.platform == "win32" else "blink2video") if not executable.exists(): raise SystemExit(f"Bundle introuvable : {executable}") if args.win7: executer([str(python), str(BASE_DIR / "verify_win7_bundle.py"), str(sortie)], "contrôle statique du bundle Windows 7") taille = sum(f.stat().st_size for f in sortie.rglob("*") if f.is_file()) print(f"\nBundle construit : {sortie}") print(f" exécutable : {executable.name}") print(f" taille : {taille / 1024 / 1024:.0f} Mo") print("\nLes vidéos (Blink_Clips, Blink_Daily…) iront dans Documents/blink2video,\n" "l'état (réglages, session) dans le dossier de données d'application du\n" "système, ou les deux dans le dossier désigné par la variable BLINK_HOME.") return 0 if __name__ == "__main__": raise SystemExit(main())