"""Gradio Space for Banuba AI Tasks API talking-avatar generation. Flow: 1. OAuth2 client_credentials token. 2. Request presigned upload URLs for image/audio. 3. Upload media to object storage. 4. Create a `video.lipsync` task. 5. Poll task status with gr.Timer and restore the latest task from BrowserState. """ from __future__ import annotations import hashlib import mimetypes import os import tempfile import threading import time import uuid from pathlib import Path from typing import Any, Dict, Optional import gradio as gr import requests try: from posthog import Posthog except Exception: # pragma: no cover - analytics must never break the app Posthog = None # type: ignore[assignment] # ----------------------------- # Configuration # ----------------------------- AUTH_BASE_URL = os.getenv("BANUBA_AUTH_BASE_URL", "https://ai.banuba.net/auth/v1").rstrip("/") API_BASE_URL = os.getenv("BANUBA_API_BASE_URL", "https://ai.banuba.net/api/v1").rstrip("/") CLIENT_ID = os.getenv("BANUBA_CLIENT_ID", "") CLIENT_SECRET = os.getenv("BANUBA_CLIENT_SECRET", "") BANUBA_SCOPE = os.getenv("BANUBA_SCOPE", "tasks:write tasks:read") POSTHOG_PROJECT_API_KEY = os.getenv("POSTHOG_PROJECT_API_KEY") or os.getenv("POSTHOG_API_KEY") POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com") # Google Analytics (gtag.js). The measurement ID is a public, client-side value. GA_MEASUREMENT_ID = os.getenv("GA_MEASUREMENT_ID", "G-YN5BXMSZHC") POLL_INTERVAL_SECONDS = float(os.getenv("POLL_INTERVAL_SECONDS", "5")) POLL_MAX_INTERVAL_SECONDS = float(os.getenv("POLL_MAX_INTERVAL_SECONDS", "20")) # Keep results inside the real system temp dir so Gradio is allowed to serve them. # On macOS /tmp is a symlink outside $TMPDIR and Gradio's cache check rejects it. RESULT_DIR = Path(os.getenv("TASK_RESULT_DIR") or (Path(tempfile.gettempdir()) / "banuba_talking_avatar_results")) RESULT_DIR.mkdir(parents=True, exist_ok=True) # Keep only the newest N result videos on disk so the ephemeral Space disk cannot # fill up. Pruned files are re-downloaded from the API on demand if needed. MAX_RESULT_FILES = int(os.getenv("MAX_RESULT_FILES", "50")) EXAMPLES_DIR = Path(os.getenv("EXAMPLES_DIR", "assets/examples")) DEFAULT_PROMPT = os.getenv( "BANUBA_DEFAULT_PROMPT", "Waist-up, direct-to-camera. I read the script confidently and friendly, " "with natural hand gestures timed to speech, brief pauses, subtle facial " "expressions. Even lighting, neutral background, steady camera", ) def _optional_int_env(name: str, default: Optional[int]) -> Optional[int]: raw = os.getenv(name) if raw is None: return default raw = raw.strip() if raw == "" or raw.lower() in {"none", "null", "auto"}: return None return int(raw) # Default max duration (seconds). Kept small to avoid slow/expensive long jobs. DEFAULT_SECONDS = _optional_int_env("BANUBA_DEFAULT_SECONDS", 4) DEFAULT_SEED = _optional_int_env("BANUBA_DEFAULT_SEED", 7) SUCCESS_STATUSES = {"COMPLETED", "SUCCEEDED", "SUCCESS"} FAILED_STATUSES = {"FAILED", "ERROR", "CANCELED", "CANCELLED"} TOKEN_LOCK = threading.Lock() TOKEN_CACHE: Dict[str, Any] = {"access_token": None, "expires_at": 0.0} POSTHOG_CLIENT = None POSTHOG_LOCK = threading.Lock() SESSION_TASKS: Dict[str, str] = {} TASK_RECORDS: Dict[str, Dict[str, Any]] = {} TASK_EVENTS_SENT: Dict[str, set[str]] = {} RECORDS_LOCK = threading.Lock() # Cap the in-memory bookkeeping so a long-running Space cannot leak memory. MAX_TRACKED_TASKS = int(os.getenv("MAX_TRACKED_TASKS", "500")) # ----------------------------- # UI strings / CSS # ----------------------------- # Load Google Analytics from the Blocks `js` hook (executed on page load) rather than # from `head=`. Under ssr_mode=False Gradio stores `head` HTML in gradio_config and # injects it client-side, which does NOT execute