build.py
Rama main · réplica de lectura del repositorio original.
"""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())