clips-studio.spec
Rama main · réplica de lectura del repositorio original.
# 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",
)