From 0ffc7c2ae33d0ef759e6dc4d53cc1c25edb9cd85 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Mon, 15 Jun 2026 01:02:11 +0200 Subject: [PATCH] Add macOS PyInstaller app build and GitHub Release workflow. Bundle the Web UI server as a headless .app with Application Support paths, system status SSE for downloads/MLX loading, and CI that builds on release publish or workflow_dispatch with custom ref/tag for pre-merge testing. Co-authored-by: Cursor --- .github/workflows/release.yml | 90 +++++++++++++++++++ README.md | 15 ++++ app_main.py | 82 +++++++++++++++++ docs/PACKAGING.md | 87 ++++++++++++++++++ ltx_mlx_backend.py | 49 +++++++++- ltx_paths.py | 84 +++++++++++++++++ pyinstaller/ltx_ws.spec | 119 ++++++++++++++++++++++++ requirements-build.txt | 2 + scripts/build_mac_app.sh | 61 +++++++++++++ scripts/ci_install_build_deps.sh | 20 +++++ server.py | 28 +++++- system_status.py | 150 +++++++++++++++++++++++++++++++ web/src/App.tsx | 2 + web/src/SystemStatus.tsx | 97 ++++++++++++++++++++ web/src/index.css | 61 ++++++++++++- web/src/types.ts | 14 +++ web_ui.py | 45 ++++++++-- 17 files changed, 995 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app_main.py create mode 100644 docs/PACKAGING.md create mode 100644 ltx_paths.py create mode 100644 pyinstaller/ltx_ws.spec create mode 100644 requirements-build.txt create mode 100755 scripts/build_mac_app.sh create mode 100755 scripts/ci_install_build_deps.sh create mode 100644 system_status.py create mode 100644 web/src/SystemStatus.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..38647f1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +# Build LTX-WS Videofentanyl macOS .app with PyInstaller and attach to GitHub Releases. +# +# Normal path: publish a GitHub Release → builds that tag and attaches the zip. +# Manual test: Actions → Release → Run workflow +# - ref: branch/SHA to build (e.g. PyInstaller) before merge +# - release_tag: label for the zip filename (e.g. ci-test, v0.2.0-rc1) +# - attach_to_release: optional upload to an existing release with that tag + +name: Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + ref: + description: "Git ref to build (branch, tag, or SHA). Empty = branch selected below." + required: false + default: "" + release_tag: + description: "Version label for the zip filename (ignored when triggered by publishing a release)." + required: true + default: ci-test + attach_to_release: + description: "Attach zip to an existing GitHub Release named release_tag (manual runs only)." + type: boolean + required: false + default: false + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.release.tag_name || inputs.release_tag || github.run_id }} + cancel-in-progress: true + +jobs: + build-macos-app: + name: macOS app (arm64) + runs-on: macos-14 + timeout-minutes: 90 + env: + RELEASE_VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} + CHECKOUT_REF: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref != '' && inputs.ref || github.ref }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.CHECKOUT_REF }} + fetch-depth: 0 + + - name: Build context + run: | + echo "event=${{ github.event_name }}" + echo "checkout_ref=${CHECKOUT_REF}" + echo "release_version=${RELEASE_VERSION}" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install Python build dependencies + run: | + chmod +x scripts/ci_install_build_deps.sh scripts/build_mac_app.sh + ./scripts/ci_install_build_deps.sh + + - name: Build macOS app + run: ./scripts/build_mac_app.sh --zip + + - name: List build output + run: ls -la dist/ + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: LTX-WS-Videofentanyl-${{ env.RELEASE_VERSION }}-macos-arm64 + path: dist/LTX-WS-Videofentanyl-*-macos-arm64.zip + if-no-files-found: error + + - name: Attach to GitHub Release + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.attach_to_release) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || inputs.release_tag }} + files: dist/LTX-WS-Videofentanyl-*-macos-arm64.zip + fail_on_unmatched_files: true + overwrite_files: true diff --git a/README.md b/README.md index a43ff6a..a757b58 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,21 @@ Build once (or after editing `web/`): cd web && npm install && npm run build && cd .. ``` +### macOS app (PyInstaller) + +Build a double-clickable **LTX-WS Videofentanyl** `.app` (no terminal; status in the Web UI header). See [docs/PACKAGING.md](docs/PACKAGING.md). + +**Download:** published GitHub Releases include `LTX-WS-Videofentanyl--macos-arm64.zip` (built by [.github/workflows/release.yml](.github/workflows/release.yml)). + +**Build locally:** + +```bash +./scripts/ci_install_build_deps.sh +./scripts/build_mac_app.sh +``` + +Output: `dist/LTX-WS Videofentanyl.app`. Models and outputs live under `~/Library/Application Support/LTX-WS/`. + ### Hugging Face auth For gated or private Hub repos: set [`HF_TOKEN`](https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables) or run `huggingface-cli login`. diff --git a/app_main.py b/app_main.py new file mode 100644 index 0000000..ae61dba --- /dev/null +++ b/app_main.py @@ -0,0 +1,82 @@ +""" +macOS app entry point for PyInstaller builds. + +Configures writable Application Support paths, file logging, and opens the +embedded Web UI in the default browser. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import threading +import time +import webbrowser + + +def _setup_logging() -> None: + from ltx_paths import configure_frozen_environment, is_frozen, logs_dir + + configure_frozen_environment() + handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] + if is_frozen(): + log_file = logs_dir() / "ltx-ws.log" + handlers.append(logging.FileHandler(log_file, encoding="utf-8")) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(message)s", + datefmt="%H:%M:%S", + handlers=handlers, + ) + + +def main() -> None: + from ltx_paths import is_frozen + + _setup_logging() + if is_frozen(): + from system_status import set_status + + set_status("idle", "Starting…") + parser = argparse.ArgumentParser(description="LTX-WS Videofentanyl") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument( + "--open-browser", + action="store_true", + default=is_frozen(), + help="open Web UI in browser (default when frozen)", + ) + parser.add_argument("--model", default="auto") + args, _unknown = parser.parse_known_args() + + if args.open_browser: + url = f"http://{args.host}:{args.port}/" + threading.Thread( + target=lambda: (time.sleep(2.0), webbrowser.open(url)), + daemon=True, + ).start() + + # Delegate to server.main with equivalent CLI flags. + argv = [ + "server.py", + "--web-ui", + "--host", + args.host, + "--port", + str(args.port), + "--model", + args.model, + ] + if args.open_browser: + argv.append("--open-browser") + sys.argv = [sys.argv[0]] + argv + + from server import main as server_main + + server_main() + + +if __name__ == "__main__": + main() diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..437ccca --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,87 @@ +# Packaging: macOS app (PyInstaller) + +Build a double-clickable **LTX-WS Videofentanyl** app for Apple Silicon. + +## Prerequisites + +- macOS on Apple Silicon +- Python 3.11+ venv with all runtime deps (`requirements.txt` + ltx-2-mlx v0.14.9) +- Node.js 18+ (`cd web && npm install`) +- PyInstaller: `pip install pyinstaller` + +## Build (local) + +```bash +chmod +x scripts/build_mac_app.sh scripts/ci_install_build_deps.sh +./scripts/ci_install_build_deps.sh +./scripts/build_mac_app.sh +``` + +Output: `dist/LTX-WS Videofentanyl.app` + +Zip for distribution: + +```bash +./scripts/build_mac_app.sh --zip +# dist/LTX-WS-Videofentanyl--macos-arm64.zip +``` + +## GitHub Releases (CI) + +Publishing a GitHub Release runs [.github/workflows/release.yml](../.github/workflows/release.yml): + +1. Builds the Web UI (`npm ci` + `vite build`) +2. Installs Python deps via `scripts/ci_install_build_deps.sh` +3. Runs PyInstaller on `macos-14` (Apple Silicon) +4. Zips the `.app` and attaches `LTX-WS-Videofentanyl--macos-arm64.zip` to the release + +**To ship a build:** create a release on GitHub (tag + publish). No local build required. + +Manual test without publishing: Actions → **Release** → **Run workflow** + +| Input | Purpose | +|-------|---------| +| **ref** | Branch or SHA to build (e.g. `PyInstaller`). Empty = branch you pick in the UI. | +| **release_tag** | Zip filename label (e.g. `ci-test`, `v0.2.0-rc1`). | +| **attach_to_release** | Upload to an existing GitHub Release with that tag (optional). | + +Produces a workflow artifact; release attachment only when publishing a release or when `attach_to_release` is enabled. + +First launch opens `http://127.0.0.1:8765/` in your browser. The app runs headless (no terminal). + +## Runtime paths (frozen) + +| Data | Location | +|------|----------| +| Models | `~/Library/Application Support/LTX-WS/models/` | +| LoRAs | `~/Library/Application Support/LTX-WS/loras/` | +| Web outputs | `~/Library/Application Support/LTX-WS/web_outputs/` | +| Logs | `~/Library/Application Support/LTX-WS/logs/ltx-ws.log` | + +Override base: `LTX_WS_DATA_DIR=/path` + +## UI status indicators + +Without a console, startup progress is shown in the Web UI header: + +- Model download (Hugging Face snapshot progress) +- MLX / pipeline loading +- LoRA download +- Active model when ready + +Subscribe API: `GET /api/system/events` (SSE), snapshot: `GET /api/system/status` + +## Dev entry (non-frozen) + +```bash +python app_main.py --open-browser --model auto +``` + +Equivalent to `python server.py --web-ui --open-browser`. + +## Notes + +- MLX weights are **not** bundled; first run downloads to Application Support. +- `ffmpeg` is not bundled; autoconcat requires `ffmpeg` on PATH. +- PyInstaller + MLX is fragile across versions; test on a clean machine after building. +- For CLI/MCP, continue using `python server.py` / `videofentanyl.py` from a venv. diff --git a/ltx_mlx_backend.py b/ltx_mlx_backend.py index 2e62eeb..f326d06 100644 --- a/ltx_mlx_backend.py +++ b/ltx_mlx_backend.py @@ -29,6 +29,8 @@ log = logging.getLogger("fvserver") +from ltx_paths import loras_dir, models_dir, repo_root as REPO_ROOT + LTX2_SPATIAL_ALIGN = 32 LTX2_MLX_GIT_TAG = "v0.14.9" @@ -96,6 +98,10 @@ def _snapshot_download_weights(snapshot_download: Any, repo_id: str, dest: Path) kw["resume_download"] = True if "local_dir_use_symlinks" in sig.parameters: kw["local_dir_use_symlinks"] = False + if "tqdm_class" in sig.parameters: + from system_status import StatusTqdm + + kw["tqdm_class"] = StatusTqdm out = snapshot_download(**kw) return str(Path(out).resolve()) @@ -126,7 +132,7 @@ def hf_local_weights_directory(repo_id: str, explicit_model_dir: str | None) -> if explicit_model_dir: return Path(explicit_model_dir).expanduser().resolve() env = os.environ.get(VIDEOFENTANYL_MODELS_ENV, "").strip() - root = Path(env).expanduser().resolve() if env else (REPO_ROOT / "models") + root = Path(env).expanduser().resolve() if env else models_dir() safe = rid.replace("/", "__") return (root / safe).resolve() @@ -231,6 +237,15 @@ def resolve_mlx_weights_directory(model: str, explicit_model_dir: str | None) -> if _model_snapshot_present(dest): log.info("Using existing local MLX snapshot for %r at %s", raw, dest) return str(dest) + from system_status import set_status + + set_status( + "downloading_model", + f"Downloading model weights: {raw}", + detail=str(dest), + model=raw, + pct=0.0, + ) log.info( "Ensuring Hugging Face weights %r under %s " "(huggingface_hub.snapshot_download; same payload as `huggingface-cli download`) …", @@ -238,6 +253,13 @@ def resolve_mlx_weights_directory(model: str, explicit_model_dir: str | None) -> dest, ) _snapshot_download_weights(snapshot_download, raw, dest) + set_status( + "idle", + "Model weights downloaded", + detail=str(dest), + model=raw, + pct=100.0, + ) return str(dest) return raw @@ -347,7 +369,7 @@ def _local_lora_cache_dir() -> Path: env = (os.environ.get(VIDEOFENTANYL_LORA_DIR_ENV) or "").strip() if env: return Path(env).expanduser().resolve() - return (REPO_ROOT / "loras").resolve() + return loras_dir() def _pick_safetensors_file(root: Path) -> Path | None: @@ -392,6 +414,13 @@ def _resolve_lora_path(spec: str) -> tuple[str, str | None]: ) from e cache_root = _local_lora_cache_dir() cache_root.mkdir(parents=True, exist_ok=True) + from system_status import set_status + + set_status( + "downloading_lora", + f"Downloading LoRA: {filename}", + detail=repo_id, + ) log.info( "Downloading/using cached LoRA %s (%s @ %s) …", repo_id, @@ -821,9 +850,13 @@ def _resolve_model_dir(self) -> str: def load(self) -> None: if self._model_path is not None: return + from system_status import set_status + + set_status("loading_mlx", "Initializing MLX…", model=self.model) try: import ltx_pipelines_mlx as lpm except ImportError as e: + set_status("error", "MLX packages missing", error=str(e)) raise RuntimeError( "Missing ltx_pipelines_mlx. Install the MLX monorepo packages, e.g.:\n" f"{ltx2_mlx_install_hint()}" @@ -894,8 +927,11 @@ def load(self) -> None: log.info("Detected spatial upscaler pipeline class: %s", cls_name) break log.info("MLX model path resolved ✓ %s", path) + set_status("ready", "Model ready", model=self.model, detail=path, pct=100.0) def _get_pipe(self, key: str, *, pipe_kwargs: dict[str, Any] | None = None) -> Any: + from system_status import set_status + if not pipe_kwargs and key in self._pipes: return self._pipes[key] self.load() @@ -907,6 +943,12 @@ def _get_pipe(self, key: str, *, pipe_kwargs: dict[str, Any] | None = None) -> A f"Unsupported pipeline key: {key} (installed ltx-2-mlx may be too old; " f"expected {LTX2_MLX_GIT_TAG}+)" ) + set_status( + "loading_pipeline", + f"Loading pipeline: {key}", + pipeline=key, + model=self.model, + ) log.info("Loading MLX pipeline %s from %s …", key, self._model_path) ctor_kwargs: dict[str, Any] = {"model_dir": self._model_path, "low_memory": self.low_memory} if pipe_kwargs: @@ -937,6 +979,9 @@ def ensure_default_loras_ready(self) -> None: if not self.default_lora_specs: self._resolved_default_loras = [] return + from system_status import set_status + + set_status("resolving_loras", "Resolving LoRA weights…", model=self.model) resolved, temps = self._resolve_lora_specs(self.default_lora_specs) for tmp in temps: if tmp and os.path.isfile(tmp) and "fvserver_lora_" in tmp: diff --git a/ltx_paths.py b/ltx_paths.py new file mode 100644 index 0000000..55ca78c --- /dev/null +++ b/ltx_paths.py @@ -0,0 +1,84 @@ +""" +Runtime path resolution for dev installs and PyInstaller-frozen macOS apps. + +Frozen layout: + sys._MEIPASS/ — bundled read-only code + web/dist + ~/Library/Application Support/LTX-WS/ — models, loras, outputs, logs (writable) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_NAME = "LTX-WS" +DATA_DIR_ENV = "LTX_WS_DATA_DIR" + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def bundle_root() -> Path: + """Read-only bundle root (PyInstaller _MEIPASS) or repo root in dev.""" + if is_frozen(): + return Path(getattr(sys, "_MEIPASS")) + return Path(__file__).resolve().parent + + +def repo_root() -> Path: + """Project / package root (code location).""" + return bundle_root() + + +def user_data_root() -> Path: + """Writable per-user data directory.""" + override = (os.environ.get(DATA_DIR_ENV) or "").strip() + if override: + return Path(override).expanduser().resolve() + if is_frozen(): + return Path.home() / "Library" / "Application Support" / APP_NAME + return repo_root() + + +def models_dir() -> Path: + env = (os.environ.get("VIDEOFENTANYL_MODELS") or "").strip() + if env: + return Path(env).expanduser().resolve() + return user_data_root() / "models" + + +def loras_dir() -> Path: + env = (os.environ.get("VIDEOFENTANYL_LORA_DIR") or "").strip() + if env: + return Path(env).expanduser().resolve() + return user_data_root() / "loras" + + +def web_outputs_dir() -> Path: + return user_data_root() / "web_outputs" + + +def web_uploads_dir() -> Path: + return user_data_root() / "web_uploads" + + +def web_dist_dir() -> Path: + return bundle_root() / "web" / "dist" + + +def logs_dir() -> Path: + return user_data_root() / "logs" + + +def configure_frozen_environment() -> None: + """Set default env paths and create writable dirs when running as a frozen app.""" + if not is_frozen(): + return + root = user_data_root() + for name in ("models", "loras", "web_outputs", "web_uploads", "logs"): + (root / name).mkdir(parents=True, exist_ok=True) + os.environ.setdefault("VIDEOFENTANYL_MODELS", str(root / "models")) + os.environ.setdefault("VIDEOFENTANYL_LORA_DIR", str(root / "loras")) + os.environ.setdefault("LTX_WS_DATA_DIR", str(root)) diff --git a/pyinstaller/ltx_ws.spec b/pyinstaller/ltx_ws.spec new file mode 100644 index 0000000..ab92c15 --- /dev/null +++ b/pyinstaller/ltx_ws.spec @@ -0,0 +1,119 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec for LTX-WS Videofentanyl macOS app. +# Build: scripts/build_mac_app.sh + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_all + +ROOT = Path(SPECPATH).resolve().parent + +block_cipher = None + +datas = [] +web_dist = ROOT / "web" / "dist" +if web_dist.is_dir(): + datas.append((str(web_dist), "web/dist")) + +hiddenimports = [ + "mlx", + "mlx.core", + "ltx_pipelines_mlx", + "ltx_core_mlx", + "huggingface_hub", + "huggingface_hub.utils", + "tqdm", + "av", + "PIL", + "multipart", + "uvicorn", + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + "starlette", + "fastapi", + "websockets", + "system_status", + "ltx_paths", + "server", + "web_ui", + "ltx_mlx_backend", + "videofentanyl", +] + +binaries = [] + +for package in ("mlx", "ltx_core_mlx", "ltx_pipelines_mlx"): + try: + pkg_datas, pkg_binaries, pkg_hidden = collect_all(package) + datas += pkg_datas + binaries += pkg_binaries + hiddenimports += pkg_hidden + except Exception as exc: + print(f"Warning: collect_all({package}) failed: {exc}") + +a = Analysis( + [str(ROOT / "app_main.py")], + pathex=[str(ROOT)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="LTX-WS-Videofentanyl", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="LTX-WS-Videofentanyl", +) + +app = BUNDLE( + coll, + name="LTX-WS Videofentanyl.app", + icon=None, + bundle_identifier="com.ltx-ws.videofentanyl", + info_plist={ + "CFBundleName": "LTX-WS Videofentanyl", + "CFBundleDisplayName": "LTX-WS Videofentanyl", + "NSHighResolutionCapable": True, + }, +) diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..6385fa3 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +# Build-time dependencies (PyInstaller macOS app). Not required for normal server usage. +pyinstaller>=6.10,<7 diff --git a/scripts/build_mac_app.sh b/scripts/build_mac_app.sh new file mode 100755 index 0000000..82e2585 --- /dev/null +++ b/scripts/build_mac_app.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build LTX-WS Videofentanyl macOS .app with PyInstaller. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +ZIP=false +for arg in "$@"; do + case "$arg" in + --zip) ZIP=true ;; + esac +done + +APP_NAME="LTX-WS Videofentanyl.app" +APP_PATH="dist/${APP_NAME}" + +echo "==> Building Web UI" +cd web +if [[ "${CI:-}" == "true" ]] && [[ -f package-lock.json ]]; then + npm ci +else + if [[ ! -d node_modules ]]; then + npm install + fi +fi +npm run build +cd "$ROOT" + +if ! python3 -c "import PyInstaller" 2>/dev/null; then + echo "==> Installing PyInstaller" + python3 -m pip install -r requirements-build.txt +fi + +echo "==> PyInstaller (onedir .app)" +python3 -m PyInstaller pyinstaller/ltx_ws.spec --noconfirm --clean + +if [[ ! -d "$APP_PATH" ]]; then + echo "Error: expected app bundle at ${APP_PATH}" + exit 1 +fi + +if [[ "$ZIP" == true ]]; then + VERSION="${RELEASE_VERSION:-}" + if [[ -z "$VERSION" ]]; then + VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" + fi + # Safe filename segment (tags may contain slashes in rare cases). + SAFE_VERSION="${VERSION//\//-}" + ZIP_NAME="LTX-WS-Videofentanyl-${SAFE_VERSION}-macos-arm64.zip" + ZIP_PATH="dist/${ZIP_NAME}" + rm -f "$ZIP_PATH" + echo "==> Zipping ${APP_NAME} → ${ZIP_NAME}" + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_PATH" + echo "Done: ${ZIP_PATH}" +else + echo "" + echo "Done: ${APP_PATH}" + echo "Logs (frozen): ~/Library/Application Support/LTX-WS/logs/ltx-ws.log" + echo "Models: ~/Library/Application Support/LTX-WS/models/" +fi diff --git a/scripts/ci_install_build_deps.sh b/scripts/ci_install_build_deps.sh new file mode 100755 index 0000000..0646834 --- /dev/null +++ b/scripts/ci_install_build_deps.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Install Python deps for PyInstaller macOS app builds (local or GitHub Actions). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TAG="${LTX2_MLX_GIT_TAG:-v0.14.9}" + +echo "==> Python: $(python3 --version)" +echo "==> LTX-2-MLX tag: ${TAG}" + +python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt +python3 -m pip install -r requirements-build.txt +python3 -m pip install \ + "ltx-core-mlx @ git+https://github.com/dgrauet/ltx-2-mlx.git@${TAG}#subdirectory=packages/ltx-core-mlx" \ + "ltx-pipelines-mlx @ git+https://github.com/dgrauet/ltx-2-mlx.git@${TAG}#subdirectory=packages/ltx-pipelines-mlx" + +python3 -c "import mlx; import ltx_pipelines_mlx; import PyInstaller; print('build deps OK')" diff --git a/server.py b/server.py index 093a33b..4471dfe 100644 --- a/server.py +++ b/server.py @@ -62,12 +62,18 @@ # ── Dependency bootstrap ─────────────────────────────────────────────────────── def _ensure(pkg: str, import_as: str | None = None): - """Import a package, auto-installing it if missing.""" + """Import a package, auto-installing it if missing (skipped in frozen apps).""" import importlib + + from ltx_paths import is_frozen + name = import_as or pkg try: return __import__(name) except ImportError: + if is_frozen(): + print(f"Error: required package '{pkg}' is missing from the app bundle.") + sys.exit(1) print(f" '{pkg}' not found — installing…", flush=True) try: subprocess.check_call( @@ -1163,6 +1169,12 @@ def build_parser() -> argparse.ArgumentParser: metavar="DIR", help="directory for Web UI generated clips (default: ./web_outputs)", ) + ui.add_argument( + "--open-browser", + action="store_true", + default=False, + help="open the Web UI in the default browser after startup", + ) return p @@ -1324,6 +1336,20 @@ def main() -> None: ) web_state.video_server = server + if args.web_ui and args.open_browser: + import threading + import time + import webbrowser + + from web_ui import public_host + + _host = public_host(args.host) + _url = f"http://{_host}:{args.port}/" + threading.Thread( + target=lambda: (time.sleep(1.5), webbrowser.open(_url)), + daemon=True, + ).start() + try: asyncio.run(server.serve(web_state)) except KeyboardInterrupt: diff --git a/system_status.py b/system_status.py new file mode 100644 index 0000000..905dd37 --- /dev/null +++ b/system_status.py @@ -0,0 +1,150 @@ +""" +In-app system status for frozen macOS builds (no console). + +Phases: idle | downloading_model | downloading_lora | loading_mlx | loading_pipeline + | resolving_loras | ready | error + +SSE: subscribers receive JSON snapshots on change. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from collections import deque +from typing import Any, AsyncIterator, Deque + +_lock = threading.Lock() +_phase = "idle" +_message = "Starting…" +_detail = "" +_pct: float | None = None +_model: str | None = None +_pipeline: str | None = None +_error: str | None = None +_log: Deque[str] = deque(maxlen=200) +_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] +_loop: asyncio.AbstractEventLoop | None = None + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + + +def snapshot() -> dict[str, Any]: + with _lock: + return { + "phase": _phase, + "message": _message, + "detail": _detail, + "pct": _pct, + "model": _model, + "pipeline": _pipeline, + "error": _error, + "frozen": __import__("ltx_paths").is_frozen(), + "log_tail": list(_log), + "updated_at": _now_iso(), + } + + +def _notify() -> None: + snap = snapshot() + loop = _loop + if loop is None or not loop.is_running(): + return + for q in list(_subscribers): + try: + loop.call_soon_threadsafe(q.put_nowait, snap) + except Exception: + pass + + +def bind_event_loop(loop: asyncio.AbstractEventLoop) -> None: + global _loop + _loop = loop + + +def log(line: str) -> None: + text = (line or "").strip() + if not text: + return + with _lock: + _log.append(text) + _notify() + + +def set_status( + phase: str, + message: str, + *, + detail: str = "", + pct: float | None = None, + model: str | None = None, + pipeline: str | None = None, + error: str | None = None, +) -> None: + global _phase, _message, _detail, _pct, _model, _pipeline, _error + with _lock: + _phase = phase + _message = message + _detail = detail + if pct is not None: + _pct = pct + if model is not None: + _model = model + if pipeline is not None: + _pipeline = pipeline + if error is not None: + _error = error + if phase != "error": + _error = None + log(f"[{phase}] {message}" + (f" — {detail}" if detail else "")) + _notify() + + +def set_download_progress(pct: float, message: str, detail: str = "") -> None: + global _phase, _message, _detail, _pct + with _lock: + phase = _phase if _phase.startswith("downloading") else "downloading_model" + pct_val = max(0.0, min(100.0, float(pct))) + _phase = phase + _pct = pct_val + _message = message + _detail = detail + set_status(phase, message, detail=detail, pct=pct_val) + + +async def subscribe() -> AsyncIterator[dict[str, Any]]: + q: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + _subscribers.append(q) + try: + await q.put(snapshot()) + while True: + yield await q.get() + finally: + _subscribers.remove(q) + + +class StatusTqdm: + """Minimal tqdm stand-in for huggingface_hub download progress.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.total = kwargs.get("total") or (args[0] if args else None) + self.n = 0 + self.desc = str(kwargs.get("desc") or "") + + def update(self, n: float = 1) -> None: + self.n += int(n) + if self.total and self.total > 0: + pct = 100.0 * self.n / float(self.total) + set_download_progress(pct, self.desc or "Downloading…", f"{self.n}/{self.total}") + + def close(self) -> None: + pass + + def __enter__(self) -> StatusTqdm: + return self + + def __exit__(self, *args: Any) -> None: + self.close() diff --git a/web/src/App.tsx b/web/src/App.tsx index 771a4ee..b99fe13 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { snapshotFromClip } from "./clipEditor"; import { applyProgressEvent } from "./progress"; +import SystemStatusBar from "./SystemStatus"; import type { Clip, Config, ProgressState } from "./types"; const API = ""; @@ -533,6 +534,7 @@ export default function App() { Videofentanyl
+ diff --git a/web/src/SystemStatus.tsx b/web/src/SystemStatus.tsx new file mode 100644 index 0000000..0543f05 --- /dev/null +++ b/web/src/SystemStatus.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import type { SystemStatus } from "./types"; + +const API = ""; + +async function fetchSystemStatus(): Promise { + const r = await fetch(`${API}/api/system/status`); + if (!r.ok) throw new Error("Failed to load system status"); + return r.json(); +} +function phaseLabel(phase: string): string { + switch (phase) { + case "downloading_model": + return "Downloading model"; + case "downloading_lora": + return "Downloading LoRA"; + case "loading_mlx": + return "Loading MLX"; + case "loading_pipeline": + return "Loading pipeline"; + case "resolving_loras": + return "Resolving LoRAs"; + case "ready": + return "Ready"; + case "error": + return "Error"; + default: + return phase; + } +} + +export default function SystemStatusBar() { + const [status, setStatus] = useState(null); + + useEffect(() => { + let closed = false; + fetchSystemStatus().then((s) => { + if (!closed) setStatus(s); + }); + const es = new EventSource(`${API}/api/system/events`); + es.onmessage = (ev) => { + try { + setStatus(JSON.parse(ev.data) as SystemStatus); + } catch { + /* ignore */ + } + }; + es.onerror = () => es.close(); + return () => { + closed = true; + es.close(); + }; + }, []); + + if (!status) { + return null; + } + + if (status.phase === "idle") { + if (!status.frozen) return null; + return ( +
+ + {status.message || "Starting…"} +
+ ); + } + + if (status.phase === "ready") { + if (status.model) { + return ( +
+ + {status.model} +
+ ); + } + return null; + } + + const pct = + status.pct != null && status.pct > 0 ? `${Math.round(status.pct)}%` : null; + + return ( +
+ + + {phaseLabel(status.phase)} + {pct ? ` ${pct}` : ""} + {status.pipeline ? ` · ${status.pipeline}` : ""} + +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index a85f494..0fb4a6d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -238,11 +238,70 @@ body { .header-status { display: flex; align-items: center; - gap: 6px; + gap: 12px; font-size: 0.8rem; color: var(--muted); } +.system-status { + display: flex; + align-items: center; + gap: 6px; + max-width: 280px; +} + +.system-status.busy { + color: #c9b8ff; +} + +.system-status.error { + color: #f87171; +} + +.system-status.ready { + color: #86efac; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.system-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.system-dot.ok { + background: #22c55e; +} + +.system-dot.busy { + background: #a78bfa; + animation: pulse 1.2s ease-in-out infinite; +} + +.system-dot.err { + background: #ef4444; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.system-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .status-dot { width: 8px; height: 8px; diff --git a/web/src/types.ts b/web/src/types.ts index b6c22e6..a8ed588 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -19,6 +19,19 @@ export interface PresetOption { seconds?: number; } +export interface SystemStatus { + phase: string; + message: string; + detail?: string; + pct?: number | null; + model?: string | null; + pipeline?: string | null; + error?: string | null; + frozen?: boolean; + log_tail?: string[]; + updated_at?: string; +} + export interface Config { server_connected: boolean; server_url: string; @@ -41,6 +54,7 @@ export interface Config { active_model?: string; lora_presets?: LoraPreset[]; default_lora_preset_id?: string; + system?: SystemStatus; } export interface Clip { diff --git a/web_ui.py b/web_ui.py index a7e88c1..33ad479 100644 --- a/web_ui.py +++ b/web_ui.py @@ -25,7 +25,14 @@ from starlette.requests import Request from starlette.websockets import WebSocket -REPO_ROOT = Path(__file__).resolve().parent +from ltx_paths import ( + is_frozen, + models_dir, + repo_root as REPO_ROOT, + web_dist_dir, + web_outputs_dir, + web_uploads_dir, +) log = logging.getLogger("web_ui") KNOWN_MODELS = [ @@ -58,8 +65,8 @@ ] CLIP_MULTIPLIER_MAX = 10 -DEFAULT_OUTPUT_DIR = REPO_ROOT / "web_outputs" -DEFAULT_UPLOAD_DIR = REPO_ROOT / "web_uploads" +DEFAULT_OUTPUT_DIR = web_outputs_dir() +DEFAULT_UPLOAD_DIR = web_uploads_dir() INDEX_FILE = "index.json" FPS = 24 PROGRESS_KEEPALIVE_INTERVAL_S = 1.0 @@ -143,7 +150,7 @@ def _ensure_lora_downloaded(spec: str) -> dict[str, Any]: def resolve_web_dist() -> Path: - return REPO_ROOT / "web" / "dist" + return web_dist_dir() def public_host(bind_host: str) -> str: @@ -208,10 +215,10 @@ def _clip_settings_from_body(body: dict[str, Any]) -> dict[str, Any]: def scan_local_models() -> list[dict[str, str]]: found: list[dict[str, str]] = [] - models_dir = REPO_ROOT / "models" - if not models_dir.is_dir(): + models_root = models_dir() + if not models_root.is_dir(): return found - for child in sorted(models_dir.iterdir()): + for child in sorted(models_root.iterdir()): if child.is_dir(): found.append( { @@ -373,6 +380,8 @@ async def emit(self, run_id: str, event: dict[str, Any]) -> None: def _ensure_web_deps() -> None: + if is_frozen(): + return import importlib import subprocess @@ -1166,6 +1175,9 @@ def create_app( @asynccontextmanager async def lifespan(app: FastAPI): + from system_status import bind_event_loop + + bind_event_loop(asyncio.get_running_loop()) state.ensure_worker() yield if state.server_process: @@ -1221,6 +1233,8 @@ async def api_config(request: Request): models = KNOWN_MODELS + local ok = await _is_connected(request) lora_presets, default_lora_preset_id = _lora_catalog() + from system_status import snapshot as system_snapshot + model_note = ( "MLX weights only (dgrauet/ltx-2.3-mlx*). " "Restart server.py with --model when changing model." @@ -1246,8 +1260,25 @@ async def api_config(request: Request): "model_note": model_note, "lora_presets": lora_presets, "default_lora_preset_id": default_lora_preset_id, + "system": system_snapshot(), } + @app.get("/api/system/status") + async def api_system_status(): + from system_status import snapshot as system_snapshot + + return system_snapshot() + + @app.get("/api/system/events") + async def api_system_events(): + from system_status import subscribe + + async def stream() -> AsyncIterator[str]: + async for snap in subscribe(): + yield f"data: {json.dumps(snap)}\n\n" + + return StreamingResponse(stream(), media_type="text/event-stream") + @app.post("/api/loras/ensure") async def ensure_lora(body: dict[str, Any]): spec = str(body.get("spec") or "").strip()