# PyInstaller spec — freezes the Python engine into resources/backend/api.exe # # Build with: python scripts/build_installer.py (don't call this directly) # # One-DIR, not one-file. One-file unpacks the whole payload to a temp folder # on every launch, and with PyTorch in the bundle that is gigabytes of copying # before the app answers its first request — slow and fragile. One-dir starts # instantly and lets the installer share files properly. # # Console app on purpose: the backend prints its progress, and Electron reads # or discards that. A windowed build sets sys.stdout to None, which turns # every print() in the pipeline into a crash. Electron passes windowsHide so # no console window is ever shown to the user. import importlib.util import sys from pathlib import Path from PyInstaller.utils.hooks import collect_all, collect_submodules ROOT = Path(SPECPATH) datas = [] binaries = [] hiddenimports = [] # Packages that resolve things at runtime rather than by import statement, so # PyInstaller's static analysis cannot see them: # yt_dlp — one extractor module per site, imported by name # ultralytics — model/task registry, plus its config yaml # faster_whisper / ctranslate2 — native libs + tokenizer assets # curl_cffi — bundled libcurl binaries # piper — espeak-ng phoneme data (19 MB) resolved from its own # package directory at runtime, plus the espeak bridge # onnxruntime — native inference libs Piper loads by name # rapidocr_onnxruntime — its OCR models (15 MB) and config.yaml, read from # its own package directory (on-screen text in a gaming stream) # shapely / pyclipper — native libs RapidOCR's text detector needs # nvidia.cublas — cuBLAS 12 (cublas64_12.dll), loaded by name by # CTranslate2 at its first GPU run; PyTorch's CUDA 13 build # only has cuBLAS 13 (transcription/transcriber.py, #111) for package in ("yt_dlp", "yt_dlp_ejs", "ultralytics", "faster_whisper", "ctranslate2", "curl_cffi", "piper", "onnxruntime", "rapidocr_onnxruntime", "shapely", "pyclipper", "nvidia.cublas"): pkg_datas, pkg_binaries, pkg_hidden = collect_all(package) datas += pkg_datas binaries += pkg_binaries hiddenimports += pkg_hidden # uvicorn picks its event loop, HTTP parser and websocket implementation at # startup by importing strings, so every backend has to be pulled in. hiddenimports += collect_submodules("uvicorn") # Sports (sports/): each sport's package is imported by name from the registry # (sports/__init__.py SPORTS), which the analyser can't follow. hiddenimports += collect_submodules("sports") # Plugins (plugins/): imported inside functions only when a job names a # pipeline plugin, so name them all rather than rely on the analyser. hiddenimports += collect_submodules("plugins") # Reusable account providers and their desktop EventSub WebSocket client. hiddenimports += collect_submodules("connected_accounts") hiddenimports += collect_submodules("stream_workflows") hiddenimports += collect_submodules("websockets") # Clipping campaigns (campaigns/): every import of it sits inside a function. hiddenimports += collect_submodules("campaigns") # Cloud AI adapters (llm/providers/adapters/): adapter_for imports each by the # name its provider spec gives, which the analyser can't follow. 2.0.0 shipped # without them and saving any cloud key failed with a 500 (#135). hiddenimports += collect_submodules("llm.providers.adapters") hiddenimports += [ "uvicorn.logging", "uvicorn.loops.auto", "uvicorn.protocols.http.auto", "uvicorn.protocols.websockets.auto", "uvicorn.lifespan.on", ] # Imported lazily inside functions, so they are invisible to the analyser. hiddenimports += [ "torch", "torchvision", "cv2", "psutil", "pynvml", "google.auth", "google_auth_oauthlib", "googleapiclient", "googleapiclient.discovery", # Gaming / Reaction: imported only when the switch is on (core/pipeline.py). "gaming.run", "gaming.detect", "gaming.layout", "gaming.framing", "gaming.compose", "gaming.panels", # Scoring a gaming stream (imported only when it is asked for). "analysis.gaming", "analysis.chat_moments", "analysis.game_audio", "analysis.panns", "analysis.game_text", "analysis.game_vision", # The second speaker's caption colour (imported only when it is ticked). "analysis.voice_turns", # Remote rendering (imported only when it is switched on). "remote_render", "remote_render.protocol", "remote_render.settings", "remote_render.tls", "remote_render.queue", "remote_render.gateway", "remote_render.piece", "remote_render.worker", "remote_render.dispatch", "remote_render.service", "cryptography.x509", "cryptography.hazmat.primitives.asymmetric.ec", "sources.preview_frames", # Runs a pipeline's Python script when main.py is started with the # plugin marker (_clipskitty_script_host.py). "_clipskitty_script_host", ] # The whole standard library, for pipelines. They run on this Python # (_clipskitty_script_host.py), and PyInstaller otherwise packs only the # modules the engine itself happens to import, so an ordinary `import csv` or # `import statistics` in a pipeline could fail in the installed app alone. # The list comes from the Python doing the freezing, so it always matches it. # Left out: GUI toolkits (tkinter is excluded below), the test suite, and # tools for building or installing Python. tests/test_packaging.py guards it. STDLIB_SKIP = { "tkinter", "_tkinter", "turtle", "turtledemo", "idlelib", "test", "ensurepip", "venv", "lib2to3", "distutils", "pydoc_data", "antigravity", "this", } for _name in sorted(sys.stdlib_module_names - STDLIB_SKIP): if _name.startswith("__"): continue try: _spec = importlib.util.find_spec(_name) except (ImportError, ValueError): _spec = None if _spec is None: # not on this platform (POSIX-only modules on Windows, and so on) continue hiddenimports.append(_name) if _spec.submodule_search_locations: hiddenimports += collect_submodules( _name, filter=lambda n: ".test" not in n and ".idle_test" not in n, on_error="ignore") # Config the app reads from disk at runtime. Prompts especially: they are # plain text on purpose so they can be tuned without touching code, and that # only holds if they ship as files. datas += [ (str(ROOT / "config" / "settings.yaml"), "config"), (str(ROOT / "config" / "prompts"), "config/prompts"), # What a gaming highlight looks like per kind of game, and chat's emote # classes (analysis/gaming.py), for scoring a gaming stream. (str(ROOT / "config" / "gaming.yaml"), "config"), # Each sport's moments, their worth and windows, and the commentary words # that mark them (sports/, the Sports toggle). (str(ROOT / "config" / "sports.yaml"), "config"), # The plugin SDK as plain files: plugins/_sdk.py imports it from here, and # every plugin process gets this folder on its PYTHONPATH (plugins/runner.py). (str(ROOT / "sdk" / "python" / "clipskitty_sdk"), "sdk/python/clipskitty_sdk"), # The SDK is MIT, unlike the rest of the app: its licence travels with it. (str(ROOT / "sdk" / "python" / "LICENSE"), "sdk/python"), # The official modes' manifests (plugins/builtin/), listed beside plugins. (str(ROOT / "plugins" / "builtin"), "plugins/builtin"), # The copy of Awesome Clips Kitty's index that ships with the app # (plugins/registry.py bundled_path): the Marketplace's listings and the # block list before the app has been online, and the copy it trusts. (str(ROOT / "plugins" / "catalog_index.json"), "plugins"), # The sound tagger's 527 class names, in its output order (analysis/panns.py). (str(ROOT / "config" / "audioset_labels.txt"), "config"), # The three-second clip a voice model is checked with before it is used # for online transcription (transcription/cloud.check_model). (str(ROOT / "transcription" / "assets"), "transcription/assets"), # The app version, for bug reports. ui/package.json is the only place the # version is written, and in a frozen build it is not on disk beside the # code — so every report from an installed copy said "app": "?" and could # not be dated. Three reports arrived that way before anyone noticed. # Bundling the file keeps one source of truth rather than stamping the # version into a generated module that could drift from it. (str(ROOT / "ui" / "package.json"), "."), # Prebuilt end cards, one per clip format the pipeline actually emits # (two canvases x 30/60fps). video/outro.py falls back to rendering one, # which takes ~20s — bundling these means the common paths never wait, # and video/mascot_art.py is imported rather than scripts/, which is not # packaged at all. (str(ROOT / "assets" / "outro"), "assets/outro"), # Gaming / Reaction's layouts and platform safe zones, read beside # gaming/framing.py at runtime (the editor has its own checked copy). (str(ROOT / "gaming" / "layouts.json"), "gaming"), ] # OpenCV Haar cascades. cv2 is a hidden import above, which ships the MODULE # and not its data directory -- so cv2.data.haarcascades resolves to a path # that does not exist in an installed build. CascadeClassifier does not raise # on a missing file; it returns an empty classifier and detectMultiScale then # fails with "(-215:Assertion failed) !empty()", which reads as a rendering # bug. That was issue #86: a job analysed a video, chose a clip, and produced # zero clips because the crop needed the face fallback. # # Only the two files video/tracker.py actually loads. collect_all("cv2") would # bring the whole data directory -- cascades for eyes, plates, bodies and the # rest -- none of which this app ever opens, in an installer already near 7 GB. try: import cv2 as _cv2 _cascade_dir = Path(_cv2.data.haarcascades) for _cascade in ("haarcascade_frontalface_default.xml", "haarcascade_profileface.xml"): _src = _cascade_dir / _cascade if _src.is_file(): datas += [(str(_src), "cascades")] except Exception: # A build machine without cv2 cannot produce a working app anyway, and the # runtime now degrades to pose-only cropping rather than crashing. pass # YOLO weights. Ultralytics would otherwise download them on first use, which # means a creator's first video stalls on a silent network fetch. for weights in ("yolov8n-pose.pt", "yolov8n.pt"): if (ROOT / weights).exists(): datas += [(str(ROOT / weights), ".")] # Active-speaker weights (TalkNet). Same reasoning as the YOLO ones: bundled # so an installed copy never fetches anything at runtime. Absent from a fresh # checkout until scripts/fetch_asd_model.py runs; the build carries on without # it and the tracker falls back to mouth-motion. _asd = ROOT / "models" / "pretrain_TalkSet.model" if _asd.exists(): datas += [(str(_asd), ".")] # The basketball detector (E-BARD's YOLOv8n, 6 MB), fetched by # scripts/fetch_basketball_model.py, for sports/basketball/crop. Without it a # basketball clip is framed with the COCO model and the rim finder. _bball = ROOT / "models" / "BODD_yolov8n_0001.pt" if _bball.exists(): datas += [(str(_bball), ".")] # Game-sound weights (PANNs MobileNetV1, 24 MB), fetched by # scripts/fetch_panns.py. Without them a gaming stream is scored on chat and # the streamer's voice only. _panns = ROOT / "models" / "panns_mobilenetv1.pth" if _panns.exists(): datas += [(str(_panns), ".")] # Voice models (pyannote segmentation 6 MB, WeSpeaker ResNet34-LM 27 MB), # fetched by scripts/fetch_voice_model.py, for analysis/voice_turns.py. Without # both, captions stay one colour whoever is talking. for _voice in ("pyannote_segmentation_3.onnx", "wespeaker_resnet34_lm.onnx"): if (ROOT / "models" / _voice).exists(): datas += [(str(ROOT / "models" / _voice), ".")] # FFmpeg, fetched by scripts/fetch_ffmpeg.py. core.binaries looks for an # ffmpeg/ folder next to the executable. vendor_ffmpeg = ROOT / "vendor" / "ffmpeg" if vendor_ffmpeg.exists(): for binary in vendor_ffmpeg.iterdir(): if binary.is_file(): datas += [(str(binary), "ffmpeg")] # QuickJS-ng, fetched by scripts/fetch_quickjs.py: runs YouTube's player # JavaScript for the YouTube cookies fallback only. core.binaries.quickjs looks # for a quickjs/ folder next to the executable. vendor_quickjs = ROOT / "vendor" / "quickjs" if vendor_quickjs.exists(): for binary in vendor_quickjs.iterdir(): if binary.is_file(): datas += [(str(binary), "quickjs")] # The Ollama runtime, fetched by scripts/fetch_ollama.py. Bundled so a creator # never has to install a second program before they can make a clip. # # Walked recursively, unlike FFmpeg's two flat binaries: ollama.exe is useless # without the GPU runners and CUDA libraries under lib/, and those have to keep # their layout relative to the executable or it falls back to CPU inference. vendor_ollama = ROOT / "vendor" / "ollama" if vendor_ollama.exists(): for item in vendor_ollama.rglob("*"): if item.is_file(): datas += [(str(item), str(Path("ollama") / item.relative_to(vendor_ollama).parent))] # Whisper weights, fetched by scripts/fetch_whisper.py. The quietest of the # bundled dependencies and the one that most needed bundling: faster-whisper # reads a bare size name as "download it from Hugging Face", so without these # a creator's first video stalls on a silent multi-gigabyte fetch that is # indistinguishable from a hang. core.binaries.whisper_model() finds them. vendor_whisper = ROOT / "vendor" / "whisper" if vendor_whisper.exists(): for item in vendor_whisper.rglob("*"): if item.is_file(): datas += [(str(item), str(Path("whisper") / item.relative_to(vendor_whisper).parent))] # Things that bloat the bundle without being used. Matplotlib and friends # arrive as transitive dependencies of ultralytics but nothing here plots. # # polars is 175 MB of that, and ultralytics declares it as a hard dependency, # so dropping it is only safe because it is never actually imported on the # paths this app uses. Verified rather than assumed: with polars blocked at # import time, both YOLO models load and predict, and polars never appears in # sys.modules. Re-check after an ultralytics upgrade — a detect/pose run that # starts wanting a dataframe would fail at runtime, not at build time. # # matplotlib is NOT here, and must not be added back. Ultralytics imports it # on the path video/tracker.py loads the model through, so excluding it made # every clip job die at the reactions stage with "No module named # 'matplotlib'" — in installed copies only, because a development machine has # it sitting in site-packages. It cost a release to find. tests/test_packaging # guards it. excludes = [ "polars", "tkinter", "PyQt5", "PySide2", "IPython", "jupyter", "notebook", "pandas", "sklearn", "scikeras", "tensorflow", "keras", ] # keyring keeps Connected Accounts tokens outside Windows only; on Windows they # go through core/secrets (DPAPI). requirements.txt doesn't install it there, # and connected_accounts/store.py imports it only off Windows, so make sure # the analyser never follows that import into the Windows build. if sys.platform == "win32": excludes.append("keyring") a = Analysis( [str(ROOT / "main.py")], pathex=[str(ROOT)], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=excludes, noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name="api", debug=False, bootloader_ignore_signals=False, strip=False, # UPX corrupts some CUDA and OpenCV DLLs and saves little on a bundle # this size — the risk is not worth it. upx=False, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, upx_exclude=[], name="backend", )