"""Build a single-file GUI executable for the current OS with PyInstaller. python build.py # -> dist/FFmpegWrapper(.exe) python build.py --ffmpeg-dir DIR # also copy ffmpeg + ffprobe from DIR next to the exe, # plus ffmpeg's LICENSE from DIR or its parent, # and pack everything into a .zip / .tar.gz PyInstaller cannot cross-compile: run this on Windows to get the .exe and on Linux to get the Linux binary (or use the CI workflow, which does both). """ from __future__ import annotations import argparse import shutil import sys from pathlib import Path ROOT = Path(__file__).resolve().parent NAME = "FFmpegWrapper" IS_WINDOWS = sys.platform.startswith("win") EXE = ".exe" if IS_WINDOWS else "" def find_ffmpeg_license(ffmpeg_dir: Path) -> Path | None: """ffmpeg's license file. BtbN and gyan.dev builds keep it one level above bin/.""" for folder in (ffmpeg_dir, ffmpeg_dir.parent): for name in ("LICENSE.txt", "LICENSE", "ffmpeg-LICENSE.txt"): if (folder / name).is_file(): return folder / name return None def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ffmpeg-dir", type=Path, help="folder containing ffmpeg and ffprobe to bundle") ap.add_argument("--onedir", action="store_true", help="build a folder instead of a single file (faster start-up)") args = ap.parse_args() if args.ffmpeg_dir: # Check before the slow PyInstaller run: ffmpeg is GPL, so its license must ship with it. ffmpeg_license = find_ffmpeg_license(args.ffmpeg_dir) if ffmpeg_license is None: print(f"error: no ffmpeg license (LICENSE.txt or LICENSE) in {args.ffmpeg_dir} or its parent " "folder. ffmpeg is GPL, so the license has to be packaged with it.", file=sys.stderr) return 1 import PyInstaller.__main__ from ffwrap import __version__ sep = ";" if IS_WINDOWS else ":" pyi_args = [ str(ROOT / "run.py"), "--name", NAME, "--windowed", "--noconfirm", "--clean", "--onedir" if args.onedir else "--onefile", "--icon", str(ROOT / "assets" / "icon.ico"), "--add-data", f"{ROOT / 'assets' / 'icon.png'}{sep}assets", "--distpath", str(ROOT / "dist"), "--workpath", str(ROOT / "build"), "--specpath", str(ROOT / "build"), # Not needed and only add weight. "--exclude-module", "tkinter", "--exclude-module", "unittest", "--exclude-module", "pydoc", ] PyInstaller.__main__.run(pyi_args) dist = ROOT / "dist" built = dist / NAME if args.onedir else dist / f"{NAME}{EXE}" print(f"\nBuilt {built}") if args.ffmpeg_dir: # Stage + ffmpeg + ffprobe + ffmpeg's license in one folder and archive it. stage = dist / "package" / NAME shutil.rmtree(stage.parent, ignore_errors=True) if args.onedir: shutil.copytree(built, stage) else: stage.mkdir(parents=True) shutil.copy2(built, stage) for tool in ("ffmpeg", "ffprobe"): src = args.ffmpeg_dir / f"{tool}{EXE}" if not src.is_file(): print(f"error: {src} not found", file=sys.stderr) return 1 shutil.copy2(src, stage) shutil.copy2(ffmpeg_license, stage / "ffmpeg-LICENSE.txt") plat ="windows-x64" if IS_WINDOWS else "linux-x64" archive = shutil.make_archive( str(dist / f"{NAME}-{__version__}-{plat}"), "zip" if IS_WINDOWS else "gztar", root_dir=stage.parent, base_dir=NAME) print(f"Packed {archive}") return 0 if __name__ == "__main__": sys.exit(main())