{
  "nbformat": 4,
  "nbformat_minor": 0,
  "metadata": {
    "colab": {
      "provenance": [],
      "gpuType": "T4"
    },
    "kernelspec": {
      "name": "python3",
      "display_name": "Python 3"
    },
    "language_info": {
      "name": "python"
    },
    "accelerator": "GPU"
  },
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "intro"
      },
      "source": [
        "# ⚡ VOXFORJA & VOICE STUDIO — Motor GPU Widescreen 16:9 & 9:16 Vertical\n",
        "### *Cinema 16:9 Full HD (Multi-Imagen 01..N + Fundido Cinemático) + Forja 9:16 (Smart Phrasing + Subtítulos Cintura)*\n",
        "\n",
        "Este motor ejecuta en la GPU Tesla T4:\n",
        "1. **Cinema 16:9 Landscape**: Secuencias de imágenes ordenadas naturalmente (`01.png`, `02.png`, etc.) con fundidos cruzados cinematográficos suaves, ondas panorámicas (1400px) y subtítulos Full HD.\n",
        "2. **VoxForja 9:16 Vertical**: Detección fonética de coros e intro con Whisper IA, subtítulos anclados a la cintura ($Y \\approx 1020\\text{px}$) y ondas neón 2026.\n",
        "3. **Google Drive Dual Sync**: Descarga directa de carpetas de canciones y fotos.\n",
        "4. **Zero-Config Master Bridge**: Conexión bidireccional automática a `https://musicstudio.cyou`.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "step1_hdr"
      },
      "source": [
        "## **Paso 1: Instalar y Verificar Dependencias**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "cellView": "form",
        "id": "step1_code"
      },
      "outputs": [],
      "source": [
        "#@title 🔍 [1/2] Instalar y Verificar Dependencias de Video, Subtítulos y Visión IA\n",
        "import subprocess, sys, os\n",
        "import torch\n",
        "\n",
        "if not torch.cuda.is_available():\n",
        "    raise RuntimeError(\"❌ No se detectó GPU. Ve a: Entorno de ejecución -> Cambiar tipo de entorno -> T4 GPU.\")\n",
        "\n",
        "gpu_name = torch.cuda.get_device_name(0)\n",
        "gpu_vram = round(torch.cuda.get_device_properties(0).total_memory / (1024**3), 2)\n",
        "print(f\"🔥 [GPU Conectada] {gpu_name} ({gpu_vram} GB VRAM)\\n\")\n",
        "\n",
        "print(\"📦 [1/4] Instalando FFmpeg y Fuentes del Sistema...\")\n",
        "!apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq ffmpeg fonts-dejavu-core fonts-freefont-ttf fonts-liberation > /dev/null 2>&1\n",
        "\n",
        "print(\"📦 [2/4] Instalando Motores de Voz y Video (EdgeTTS, gTTS, Websockets, OpenCV, GDown)...\")\n",
        "!pip install -q --no-warn-conflicts edge-tts gTTS \"websockets>=13.0\" nest_asyncio urllib3 requests opencv-python-headless gdown\n",
        "\n",
        "print(\"📦 [3/4] Instalando Faster-Whisper IA y Librerías CUDA 12...\")\n",
        "!pip install -q --no-warn-conflicts faster-whisper nvidia-cublas-cu12 nvidia-cudnn-cu12\n",
        "\n",
        "print(\"📦 [4/4] Instalando Motor de Clonación Zero-Shot IA (Coqui XTTS-v2 & SoundFile)...\")\n",
        "!pip install -q --no-warn-conflicts coqui-tts coqui-tts-trainer soundfile librosa\n",
        "\n",
        "print(\"\\n🧪 Verificando herramientas en Python:\")\n",
        "ff_check = subprocess.run([\"ffmpeg\", \"-version\"], capture_output=True, text=True)\n",
        "if ff_check.returncode == 0:\n",
        "    print(\"  ✅ [1/7] FFmpeg Video Engine: OPERATIVO\")\n",
        "else:\n",
        "    print(\"  ❌ [1/7] FFmpeg Video Engine: ERROR\")\n",
        "\n",
        "try:\n",
        "    import faster_whisper\n",
        "    print(\"  ✅ [2/7] Faster-Whisper Subtítulos IA: OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [2/7] Faster-Whisper: ERROR ({e})\")\n",
        "\n",
        "try:\n",
        "    import cv2\n",
        "    print(\"  ✅ [3/7] OpenCV Smart Face & Layout Detector: OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [3/7] OpenCV: ERROR ({e})\")\n",
        "\n",
        "try:\n",
        "    import gdown\n",
        "    print(\"  ✅ [4/7] Google Drive Sync (gdown): OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [4/7] Google Drive Sync: ERROR ({e})\")\n",
        "\n",
        "try:\n",
        "    import edge_tts\n",
        "    print(\"  ✅ [5/7] Motor de Voz Neuronal (EdgeTTS): OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [5/7] Motor de Voz Neuronal: ERROR ({e})\")\n",
        "\n",
        "try:\n",
        "    import websockets\n",
        "    print(\"  ✅ [6/7] Puente de Comunicación en Vivo: OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [6/7] Websockets: ERROR ({e})\")\n",
        "\n",
        "try:\n",
        "    import torch\n",
        "    import transformers.pytorch_utils\n",
        "    if not hasattr(transformers.pytorch_utils, 'isin_mps_friendly'):\n",
        "        transformers.pytorch_utils.isin_mps_friendly = torch.isin\n",
        "    from TTS.api import TTS\n",
        "    print(\"  ✅ [7/7] Motor de Clonación Vocal Multilingüe (Coqui XTTS-v2): OPERATIVO\")\n",
        "except Exception as e:\n",
        "    print(f\"  ❌ [7/7] Motor de Clonación Vocal (XTTS-v2): ERROR ({e})\")\n",
        "\n",
        "print(\"\\n🚀 ¡TODAS LAS DEPENDENCIAS CONFIRMADAS! Ejecuta el Paso 2 para conectar.\\n\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "step2_hdr"
      },
      "source": [
        "## **Paso 2: Conectar Worker GPU a `https://musicstudio.cyou`**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "cellView": "form",
        "id": "step2_code"
      },
      "outputs": [],
      "source": [
        "#@title 🌐 [2/2] Iniciar Worker Daemon con Motores 9:16 Vertical, Cinema 16:9 y Voice Studio\nimport subprocess, sys, os, glob, ctypes, time, re, shutil, ssl, urllib3, warnings\nfrom pathlib import Path\nimport numpy as np\n\n# Patch de compatibilidad transformers para Coqui TTS\ntry:\n    import torch\n    import transformers.pytorch_utils\n    if not hasattr(transformers.pytorch_utils, 'isin_mps_friendly'):\n        transformers.pytorch_utils.isin_mps_friendly = torch.isin\nexcept Exception:\n    pass\n\n# Configurar librerías CUDA para CTranslate2 y Faster-Whisper\ndef setup_cuda_libraries():\n    paths = [\"/usr/local/cuda/lib64\", \"/usr/local/cuda-12/lib64\", \"/usr/local/cuda-11/lib64\"]\n    for p in sys.path:\n        for lib_dir in glob.glob(os.path.join(p, \"nvidia\", \"*\", \"lib\")):\n            if os.path.exists(lib_dir):\n                paths.append(lib_dir)\n                for so in glob.glob(os.path.join(lib_dir, \"*.so*\")):\n                    try:\n                        ctypes.CDLL(so)\n                    except Exception:\n                        pass\n    for path in paths:\n        if os.path.exists(path) and path not in os.environ.get(\"LD_LIBRARY_PATH\", \"\"):\n            os.environ[\"LD_LIBRARY_PATH\"] = path + \":\" + os.environ.get(\"LD_LIBRARY_PATH\", \"\")\n\nsetup_cuda_libraries()\n\n# Auto-instalar dependencias automáticamente si faltan de forma individual\ndef ensure_dependencies():\n    packages = [\n        (\"faster_whisper\", \"faster-whisper\"),\n        (\"cv2\", \"opencv-python-headless\"),\n        (\"gdown\", \"gdown\"),\n        (\"gtts\", \"gTTS\"),\n        (\"edge_tts\", \"edge-tts\"),\n        (\"websockets\", \"websockets>=13.0\"),\n        (\"nest_asyncio\", \"nest_asyncio\")\n    ]\n    for mod_name, pkg_name in packages:\n        try:\n            __import__(mod_name)\n        except Exception:\n            try:\n                subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"--no-warn-conflicts\", pkg_name])\n            except Exception:\n                pass\n    try:\n        import torch\n        import transformers.pytorch_utils\n        if not hasattr(transformers.pytorch_utils, 'isin_mps_friendly'):\n            transformers.pytorch_utils.isin_mps_friendly = torch.isin\n    except Exception:\n        pass\n    try:\n        from TTS.api import TTS\n    except Exception:\n        try:\n            print(\"📦 Auto-instalando Motor de Clonación Vocal Multilingüe (Coqui XTTS-v2 & SoundFile)...\", flush=True)\n            subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"--no-warn-conflicts\", \"coqui-tts\", \"coqui-tts-trainer\", \"soundfile\", \"librosa\"])\n        except Exception as e_inst:\n            print(f\"ℹ️ Advertencia en instalación de clonador: {e_inst}\", flush=True)\n\n    setup_cuda_libraries()\n\nensure_dependencies()\n\nimport asyncio, websockets, json, requests, torch, nest_asyncio\nimport cv2\n\nwarnings.filterwarnings(\"ignore\")\nurllib3.disable_warnings()\nnest_asyncio.apply()\n\nMASTER_WS_URL = \"wss://musicstudio.cyou/ws/worker?token=wentix_gpu_secret_token_2026\"\nMASTER_HTTP_URL = \"https://musicstudio.cyou\"\n\nDEVICE = \"cuda:0\" if torch.cuda.is_available() else \"cpu\"\nGPU_NAME = torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"CPU\"\nGPU_VRAM = f\"{round(torch.cuda.get_device_properties(0).total_memory / (1024**3), 1)} GB\" if torch.cuda.is_available() else \"0 GB\"\n\nssl_context = ssl.create_default_context()\nssl_context.check_hostname = False\nssl_context.verify_mode = ssl.CERT_NONE\n\nCURRENT_CANCELLED_JOBS = set()\nACTIVE_RUNNING_JOBS = set()\n\n# --- 16 ESTILOS VIRALES CON BORDE NEGRO EXTRA GRUESO (8px) Y SOMBRA DE IMPACTO ---\nVIRAL_CAPTION_STYLES = [\n    {\"id\": \"hormozi_1w_yellow\", \"name\": \"Hormozi Classic (1 Palabra Amarillo Oro)\", \"chunk\": 1, \"size\": 78, \"c1\": \"&H00D7FF&\", \"c2\": \"&H00D7FF&\", \"bicolor\": False, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"hormozi_2w_bicolor\", \"name\": \"Hormozi 2.0 (2 Palabras Amarillo & Blanco)\", \"chunk\": 2, \"size\": 66, \"c1\": \"&H00D7FF&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"beast_1w_cyan\", \"name\": \"Beast Mode (1 Palabra Cyan Pop)\", \"chunk\": 1, \"size\": 80, \"c1\": \"&HFFFF00&\", \"c2\": \"&HFFFF00&\", \"bicolor\": False, \"bord\": 9, \"shad\": 4},\n    {\"id\": \"beast_1w_lime\", \"name\": \"Beast Glow (1 Palabra Verde Neón)\", \"chunk\": 1, \"size\": 80, \"c1\": \"&H14FF39&\", \"c2\": \"&H14FF39&\", \"bicolor\": False, \"bord\": 9, \"shad\": 4},\n    {\"id\": \"two_words_gold\", \"name\": \"Two Words Gold (2 Palabras Oro & Blanco)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&H00C8FF&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"two_words_cyan\", \"name\": \"Two Words Cyan (2 Palabras Cyan & Blanco)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&HFFFF00&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"fire_word_red\", \"name\": \"Fire Word (1 Palabra Rojo Fuego)\", \"chunk\": 1, \"size\": 82, \"c1\": \"&H0022FF&\", \"c2\": \"&H0022FF&\", \"bicolor\": False, \"bord\": 9, \"shad\": 5},\n    {\"id\": \"devin_1w_magenta\", \"name\": \"Devin Magenta (1 Palabra Hot Pink)\", \"chunk\": 1, \"size\": 78, \"c1\": \"&HF000FF&\", \"c2\": \"&HF000FF&\", \"bicolor\": False, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"iman_1w_white_titan\", \"name\": \"Iman Titan (1 Palabra Blanco Puro con Borde 9px)\", \"chunk\": 1, \"size\": 82, \"c1\": \"&HFFFFFF&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": False, \"bord\": 9, \"shad\": 5},\n    {\"id\": \"ali_2w_clean_white\", \"name\": \"Ali Clean (2 Palabras Blanco con Borde 8px)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&HFFFFFF&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": False, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"two_words_pink\", \"name\": \"Two Words Pink (2 Palabras Rosa & Blanco)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&HFF1493&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"cyber_duo_cyan_purple\", \"name\": \"Cyber Duo (2 Palabras Cyan & Púrpura)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&HFFFF00&\", \"c2\": \"&HFF00AA&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"power_1w_orange\", \"name\": \"Power Orange (1 Palabra Naranja Intenso)\", \"chunk\": 1, \"size\": 80, \"c1\": \"&H0077FF&\", \"c2\": \"&H0077FF&\", \"bicolor\": False, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"emerald_1w_neon\", \"name\": \"Emerald Neon (1 Palabra Esmeralda Neón)\", \"chunk\": 1, \"size\": 80, \"c1\": \"&H55FF00&\", \"c2\": \"&H55FF00&\", \"bicolor\": False, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"two_words_lime_gold\", \"name\": \"Two Words Lime (2 Palabras Verde Lima & Blanco)\", \"chunk\": 2, \"size\": 64, \"c1\": \"&H14FF39&\", \"c2\": \"&HFFFFFF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4},\n    {\"id\": \"pure_white_pop_2w\", \"name\": \"Pure Pop 2W (2 Palabras Blanco & Oro)\", \"chunk\": 2, \"size\": 66, \"c1\": \"&HFFFFFF&\", \"c2\": \"&H00D7FF&\", \"bicolor\": True, \"bord\": 8, \"shad\": 4}\n]\n\n# --- 6 ONDAS MODERNAS 9:16: 100% OPACIDAD SÓLIDA, ALTO BRILLO NEÓN ---\nMODERN_WAVEFORMS_916 = [\n    {\"name\": \"Micro-Píldoras Spotify Canvas\", \"filter\": \"[1:a]showwaves=s=880x95:mode=p2p:colors=#00F5FF@1.0|#FFFFFF@0.9:scale=sqrt[wave]\", \"base_y\": 1420},\n    {\"name\": \"Línea Láser Cyber Horizon\", \"filter\": \"[1:a]showwaves=s=920x80:mode=line:colors=#FF007F@1.0|#FFD700@0.9:scale=lin[wave]\", \"base_y\": 1420},\n    {\"name\": \"Puntos Flotantes LED Neón\", \"filter\": \"[1:a]showwaves=s=880x90:mode=point:colors=#FFD700@1.0|#FFFFFF@0.9:scale=lin[wave]\", \"base_y\": 1420},\n    {\"name\": \"Dual-Micro Simétrico\", \"filter\": \"[1:a]showwaves=s=860x95:mode=p2p:colors=#A855F7@1.0|#00F5FF@0.9:scale=log[wave]\", \"base_y\": 1420},\n    {\"name\": \"Cinta Fluida Dinámica\", \"filter\": \"[1:a]showwaves=s=900x85:mode=line:colors=#00FF66@1.0|#FFFFFF@0.9:scale=cbrt[wave]\", \"base_y\": 1420},\n    {\"name\": \"Barras Ecualizador Glow\", \"filter\": \"[1:a]showfreqs=s=880x95:mode=bar:colors=#38BDF8@1.0|#FF007F@0.9:scale=sqrt[wave]\", \"base_y\": 1420}\n]\n\n# --- ONDAS PANORÁMICAS 16:9 (1400px ANCHO) ---\nPANORAMIC_WAVEFORMS_169 = {\n    \"spotify_horizontal\": {\"name\": \"Spotify Panorámica\", \"filter\": \"showwaves=s=1400x90:mode=p2p:colors=#00F5FF@1.0|#FFFFFF@0.9:scale=sqrt[wave]\", \"y\": 940},\n    \"laser_cyber\": {\"name\": \"Láser Cyber Horizon\", \"filter\": \"showwaves=s=1400x80:mode=line:colors=#FF007F@1.0|#FFD700@0.9:scale=lin[wave]\", \"y\": 945},\n    \"neon_bars\": {\"name\": \"Barras Ecualizador\", \"filter\": \"showfreqs=s=1400x90:mode=bar:colors=#38BDF8@1.0|#FF007F@0.9:scale=sqrt[wave]\", \"y\": 940},\n    \"dual_wave\": {\"name\": \"Dual-Micro Neón\", \"filter\": \"showwaves=s=1400x90:mode=p2p:colors=#A855F7@1.0|#00F5FF@0.9:scale=log[wave]\", \"y\": 940},\n    \"fluid_wave\": {\"name\": \"Cinta Fluida\", \"filter\": \"showwaves=s=1400x85:mode=line:colors=#00FF66@1.0|#FFFFFF@0.9:scale=cbrt[wave]\", \"y\": 942}\n}\n\n# --- 🎯 MOTOR DE POSICIONAMIENTO VISUAL 9:16: SUBTÍTULOS ANCLADOS ESTRICTAMENTE A LA CINTURA (Y ≈ 1020px) ---\ndef analyze_smart_layout(image_path):\n    try:\n        img = cv2.imread(str(image_path))\n        if img is None:\n            return {\"detected\": False, \"wave_y\": 1420, \"sub_margin_v\": 900, \"layout\": \"DEFAULT_WAIST\"}\n        \n        h, w = img.shape[:2]\n        scale_y = 1920.0 / h\n        scale_x = 1080.0 / w\n        \n        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n        blurred = cv2.bilateralFilter(gray, 9, 75, 75)\n        edges = cv2.Canny(blurred, 40, 140)\n        kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))\n        dilated = cv2.dilate(edges, kernel, iterations=2)\n        contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n        \n        candidate_cards = []\n        for cnt in contours:\n            x, y, cw, ch = cv2.boundingRect(cnt)\n            norm_x = int(x * scale_x)\n            norm_y = int(y * scale_y)\n            norm_w = int(cw * scale_x)\n            norm_h = int(ch * scale_y)\n            \n            if norm_w >= 450 and 100 <= norm_h <= 900:\n                aspect = norm_w / float(norm_h)\n                if 1.1 <= aspect <= 7.5:\n                    candidate_cards.append({\n                        \"ymin\": norm_y,\n                        \"ymax\": norm_y + norm_h,\n                        \"height\": norm_h,\n                        \"center_y\": norm_y + (norm_h // 2),\n                        \"width\": norm_w,\n                        \"area\": norm_w * norm_h\n                    })\n        \n        best_card = None\n        if candidate_cards:\n            candidate_cards.sort(key=lambda c: (c[\"center_y\"] > 700, c[\"area\"]), reverse=True)\n            best_card = candidate_cards[0]\n        \n        if best_card:\n            card_ymin = best_card[\"ymin\"]\n            card_ymax = best_card[\"ymax\"]\n            wave_y = min(1700, max(1350, card_ymax + 15))\n            target_sub_y = min(1040, max(980, card_ymin - 120))\n            sub_margin_v = 1920 - target_sub_y\n            layout = \"WAIST_ABOVE_PLAYER\"\n        else:\n            wave_y = 1420\n            target_sub_y = 1020\n            sub_margin_v = 900\n            layout = \"WAIST_STANDARD\"\n            \n        print(f\"🎯 [Smart Layout] {layout} -> Subtítulos en CINTURA: Y={target_sub_y}px (MarginV={sub_margin_v}) | Onda: Y={wave_y}px\", flush=True)\n        return {\n            \"detected\": True if best_card else False,\n            \"wave_y\": int(wave_y),\n            \"sub_margin_v\": int(sub_margin_v),\n            \"layout\": layout,\n            \"card\": best_card\n        }\n    except Exception as e:\n        print(f\"ℹ️ Smart Layout fallback: {e}\", flush=True)\n        return {\"detected\": False, \"wave_y\": 1420, \"sub_margin_v\": 900, \"layout\": \"DEFAULT_WAIST\"}\n\n# Normalizador de URLs\ndef normalize_asset_url(url_or_path):\n    if not url_or_path:\n        return \"\"\n    u = str(url_or_path).strip()\n    if u.startswith(\"http://\") or u.startswith(\"https://\"):\n        return u\n    if u.startswith(\"/\"):\n        return f\"{MASTER_HTTP_URL}{u}\"\n    return f\"{MASTER_HTTP_URL}/{u}\"\n\n# Ordenamiento natural alfanumérico (01.png, 02.png, ..., 10.png)\ndef natural_sort_key(s):\n    return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\\d+)', str(s))]\n\n# Obtener duración exacta de un archivo con ffprobe\ndef get_media_duration(file_path):\n    try:\n        cmd = [\n            \"ffprobe\", \"-v\", \"error\",\n            \"-show_entries\", \"format=duration\",\n            \"-of\", \"default=noprint_wrappers=1:nokey=1\",\n            str(file_path)\n        ]\n        res = subprocess.run(cmd, capture_output=True, text=True, check=True)\n        return float(res.stdout.strip())\n    except Exception:\n        return 0.0\n\n# Descarga robusta con streaming y reintentos\ndef download_file_robust(raw_url, dest_path, max_retries=3):\n    url = normalize_asset_url(raw_url)\n    if not url:\n        return False\n    for attempt in range(max_retries):\n        try:\n            with requests.get(url, verify=False, stream=True, timeout=(10, 60)) as r:\n                r.raise_for_status()\n                with open(dest_path, \"wb\") as f:\n                    for chunk in r.iter_content(chunk_size=65536):\n                        if chunk:\n                            f.write(chunk)\n            if dest_path.exists() and dest_path.stat().st_size > 0:\n                return True\n        except Exception as e:\n            print(f\"⚠️ Reintento descarga ({attempt+1}/{max_retries}) de {url}: {e}\", flush=True)\n            time.sleep(2)\n    return False\n\n# Subida robusta de video individual\ndef upload_single_video_robust(job_id, video_path, is_final=False, max_retries=3):\n    v_path = Path(video_path)\n    if not v_path.exists() or v_path.stat().st_size == 0:\n        return False\n    for attempt in range(max_retries):\n        try:\n            with open(v_path, \"rb\") as f:\n                files = [(\"videos\", (v_path.name, f, \"video/mp4\"))]\n                data = {\n                    \"jobId\": job_id,\n                    \"isChunk\": \"true\",\n                    \"isFinal\": \"true\" if is_final else \"false\"\n                }\n                r = requests.post(\n                    f\"{MASTER_HTTP_URL}/api/worker/upload-result\",\n                    data=data,\n                    files=files,\n                    verify=False,\n                    timeout=(15, 180)\n                )\n                if r.status_code == 200:\n                    return True\n        except Exception as e:\n            print(f\"⚠️ Reintento subida ({attempt+1}/{max_retries}) de {v_path.name}: {e}\", flush=True)\n            time.sleep(3)\n    return False\n\n# Lazy-load Faster-Whisper\nwhisper_model = None\ndef get_whisper():\n    global whisper_model\n    if whisper_model is None:\n        setup_cuda_libraries()\n        try:\n            from faster_whisper import WhisperModel\n            print(\"⚡ Cargando Faster-Whisper IA (medium en GPU)...\", flush=True)\n            whisper_model = WhisperModel(\"medium\", device=\"cuda\", compute_type=\"float16\", download_root=\"/tmp/whisper_models\")\n        except Exception as e_gpu:\n            print(f\"ℹ️ Inicializando Faster-Whisper en CPU: {e_gpu}\", flush=True)\n            from faster_whisper import WhisperModel\n            whisper_model = WhisperModel(\"medium\", device=\"cpu\", compute_type=\"int8\", download_root=\"/tmp/whisper_models\")\n    return whisper_model\n\n# Lazy-load Motor de Clonación Vocal Multilingüe (Coqui XTTS-v2)\nvoice_cloner_model = None\n\ndef get_voice_cloner():\n    global voice_cloner_model\n    if voice_cloner_model is None:\n        print(\"⚡ Cargando Motor Coqui XTTS-v2 Multilingüe (Español Latino/Castellano & 17 Idiomas) en GPU...\", flush=True)\n        try:\n            import torch\n            import transformers.pytorch_utils\n            if not hasattr(transformers.pytorch_utils, 'isin_mps_friendly'):\n                transformers.pytorch_utils.isin_mps_friendly = torch.isin\n        except Exception:\n            pass\n        os.environ[\"COQUI_TOS_AGREED\"] = \"1\"\n        try:\n            from TTS.api import TTS\n            voice_cloner_model = TTS(\"tts_models/multilingual/multi-dataset/xtts_v2\").to(DEVICE)\n            print(\"✅ Motor Coqui XTTS-v2 listo en GPU.\", flush=True)\n        except Exception as e_xtts:\n            print(f\"❌ Error cargando Coqui XTTS-v2 en GPU: {e_xtts}\", flush=True)\n            raise e_xtts\n    return voice_cloner_model\n\ndef decode_audio_safe(audio_path):\n    cmd = [\n        \"ffmpeg\", \"-nostdin\", \"-threads\", \"0\", \"-i\", str(audio_path),\n        \"-f\", \"s16le\", \"-ac\", \"1\", \"-acodec\", \"pcm_s16le\", \"-ar\", \"16000\", \"-\"\n    ]\n    out = subprocess.run(cmd, capture_output=True, check=True).stdout\n    return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0\n\n# Smart Phrasing para 9:16\ndef extract_smart_phrase_segments(audio_path, variants_count=10, requested_duration=None):\n    total_dur = get_media_duration(audio_path)\n    if total_dur <= 0: total_dur = 180.0\n    \n    if requested_duration and str(requested_duration).isdigit() and int(requested_duration) > 0 and int(requested_duration) != 30:\n        fixed_dur = int(requested_duration)\n        blocks = []\n        max_st = max(0, total_dur - fixed_dur)\n        for i in range(variants_count):\n            st = round(i * (max_st / max(1, variants_count - 1)), 1) if variants_count > 1 else 0.0\n            blocks.append({\"start\": st, \"duration\": fixed_dur, \"label\": f\"Fixed {fixed_dur}s [{st}s]\"})\n        return blocks\n        \n    print(\"🎼 [Smart Phrasing] Escaneando frases musicales y silencios con Whisper IA...\", flush=True)\n    model = get_whisper()\n    try:\n        audio_data = decode_audio_safe(audio_path)\n        segments, _ = model.transcribe(audio_data, word_timestamps=True, language=\"es\")\n    except Exception:\n        segments, _ = model.transcribe(str(audio_path), word_timestamps=True, language=\"es\")\n        \n    all_words = []\n    for seg in segments:\n        if hasattr(seg, 'words') and seg.words:\n            for w in seg.words:\n                if w.word.strip():\n                    all_words.append({\"word\": w.word.strip(), \"start\": w.start, \"end\": w.end})\n        else:\n            if seg.text.strip():\n                all_words.append({\"word\": seg.text.strip(), \"start\": seg.start, \"end\": seg.end})\n                \n    if not all_words:\n        blocks = []\n        max_st = max(0, total_dur - 30)\n        for i in range(variants_count):\n            st = round(i * (max_st / max(1, variants_count - 1)), 1) if variants_count > 1 else 0.0\n            blocks.append({\"start\": st, \"duration\": min(30.0, total_dur - st), \"label\": f\"Instrumental [{st}s]\"})\n        return blocks\n\n    phrases = []\n    curr = [all_words[0]]\n    for w in all_words[1:]:\n        pause = w[\"start\"] - curr[-1][\"end\"]\n        if pause >= 1.1:\n            phrases.append({\"start\": curr[0][\"start\"], \"end\": curr[-1][\"end\"], \"text\": \" \".join([x[\"word\"] for x in curr])})\n            curr = [w]\n        else:\n            curr.append(w)\n    if curr:\n        phrases.append({\"start\": curr[0][\"start\"], \"end\": curr[-1][\"end\"], \"text\": \" \".join([x[\"word\"] for x in curr])})\n\n    smart_blocks = []\n    idx = 0\n    while idx < len(phrases):\n        b_start = max(0.0, phrases[idx][\"start\"] - 0.25)\n        j = idx\n        b_end = phrases[j][\"end\"]\n        while j < len(phrases) - 1:\n            next_end = phrases[j+1][\"end\"]\n            cand_dur = next_end - b_start\n            if cand_dur <= 38.0:\n                j += 1\n                b_end = phrases[j][\"end\"]\n            else:\n                break\n        \n        final_end = min(total_dur, b_end + 0.35)\n        dur = round(final_end - b_start, 2)\n        if dur >= 15.0:\n            smart_blocks.append({\n                \"start\": round(b_start, 2),\n                \"duration\": dur,\n                \"label\": f\"Frase Musical [{b_start:.1f}s - {final_end:.1f}s]\"\n            })\n        idx = j + 1\n\n    if not smart_blocks:\n        smart_blocks.append({\"start\": 0.0, \"duration\": min(30.0, total_dur), \"label\": \"Track Intro\"})\n\n    result_blocks = []\n    for k in range(variants_count):\n        block = smart_blocks[k % len(smart_blocks)]\n        result_blocks.append(block)\n        \n    print(f\"✅ [Smart Phrasing] Generados {len(result_blocks)} fragmentos musicales con corte limpio.\", flush=True)\n    return result_blocks\n\ndef extract_audio_segment(source_audio, output_segment, start_offset, duration=30):\n    fade_out_st = max(0.1, duration - 0.55)\n    audio_filter = f\"afade=t=in:ss=0:d=0.25,afade=t=out:st={fade_out_st:.2f}:d=0.55\"\n    cmd = [\n        \"ffmpeg\", \"-y\",\n        \"-ss\", str(start_offset),\n        \"-t\", str(duration),\n        \"-i\", str(source_audio),\n        \"-af\", audio_filter,\n        \"-c:a\", \"libmp3lame\", \"-b:a\", \"320k\",\n        str(output_segment)\n    ]\n    subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)\n    return os.path.exists(output_segment)\n\ndef format_ass_time(seconds):\n    h = int(seconds // 3600)\n    m = int((seconds % 3600) // 60)\n    s = int(seconds % 60)\n    cs = int(round((seconds - int(seconds)) * 100))\n    if cs >= 100: cs = 99\n    return f\"{h:d}:{m:02d}:{s:02d}.{cs:02d}\"\n\ndef create_default_gradient_cover(out_img_path, title=\"Wentix Voice\", subtitle=\"@wentixai\"):\n    try:\n        import numpy as np\n        import cv2\n        img = np.zeros((1920, 1080, 3), dtype=np.uint8)\n        for y in range(1920):\n            r = int(4 + (15 - 4) * (y / 1920))\n            g = int(8 + (25 - 8) * (y / 1920))\n            b = int(18 + (45 - 18) * (y / 1920))\n            img[y, :] = (b, g, r)\n        cv2.circle(img, (540, 750), 320, (60, 40, 15), -1)\n        cv2.imwrite(str(out_img_path), img)\n    except Exception:\n        cmd = [\n            \"ffmpeg\", \"-y\", \"-f\", \"lavfi\", \"-i\", \"color=c=0x090b14:s=1080x1920:d=1\",\n            \"-frames:v\", \"1\", str(out_img_path)\n        ]\n        subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n\n# Generar subtítulos ASS para 9:16 (1080x1920)\ndef transcribe_audio_to_ass(audio_path, ass_path, style_idx=0, style_index=None, sub_margin_v=900, **kwargs):\n    if style_index is not None:\n        style_idx = style_index\n    try:\n        model = get_whisper()\n        try:\n            audio_data = decode_audio_safe(audio_path)\n            segments, _ = model.transcribe(audio_data, word_timestamps=True, language=\"es\")\n        except Exception:\n            segments, _ = model.transcribe(str(audio_path), word_timestamps=True, language=\"es\")\n            \n        style = VIRAL_CAPTION_STYLES[style_idx % len(VIRAL_CAPTION_STYLES)]\n        chunk_size = style.get(\"chunk\", 1)\n        font_size = style.get(\"size\", 76)\n        bord_w = style.get(\"bord\", 8)\n        shad_w = style.get(\"shad\", 4)\n        \n        ass_lines = [\n            \"[Script Info]\",\n            \"ScriptType: v4.00+\",\n            \"PlayResX: 1080\",\n            \"PlayResY: 1920\",\n            \"\",\n            \"[V4+ Styles]\",\n            \"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\",\n            f\"Style: Default,DejaVu Sans,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,{bord_w},{shad_w},2,40,40,{sub_margin_v},1\",\n            \"\",\n            \"[Events]\",\n            \"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\"\n        ]\n        \n        for segment in segments:\n            words = list(segment.words)\n            if not words:\n                start = format_ass_time(segment.start)\n                end = format_ass_time(segment.end)\n                txt = segment.text.strip().upper()\n                styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{txt}\"\n                ass_lines.append(f\"Dialogue: 0,{start},{end},Default,,0,0,0,,{styled_text}\")\n                continue\n                \n            for i in range(0, len(words), chunk_size):\n                chunk = words[i:i + chunk_size]\n                chunk_start = format_ass_time(chunk[0].start)\n                chunk_end = format_ass_time(chunk[-1].end)\n                \n                if style.get(\"bicolor\") and len(chunk) > 1:\n                    w1 = chunk[0].word.strip().upper()\n                    w2 = \" \".join([w.word.strip().upper() for w in chunk[1:]])\n                    styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w1} {{\\\\1c{style['c2']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w2}\"\n                else:\n                    w_text = \" \".join([w.word.strip().upper() for w in chunk])\n                    styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w_text}\"\n                \n                ass_lines.append(f\"Dialogue: 0,{chunk_start},{chunk_end},Default,,0,0,0,,{styled_text}\")\n                \n        with open(str(ass_path), \"w\", encoding=\"utf-8\") as f:\n            f.write(\"\\n\".join(ass_lines))\n        return True\n    except Exception as e:\n        print(f\"⚠️ Error generando subtítulos ASS: {e}\", flush=True)\n        return False\n\n# Generar subtítulos ASS para 16:9 Landscape (1920x1080)\ndef transcribe_audio_to_ass_169(audio_path, ass_path, style_key=\"hormozi_1w_yellow\"):\n    try:\n        model = get_whisper()\n        try:\n            audio_data = decode_audio_safe(audio_path)\n            segments, _ = model.transcribe(audio_data, word_timestamps=True, language=\"es\")\n        except Exception:\n            segments, _ = model.transcribe(str(audio_path), word_timestamps=True, language=\"es\")\n            \n        style = next((s for s in VIRAL_CAPTION_STYLES if s[\"id\"] == style_key), VIRAL_CAPTION_STYLES[0])\n        chunk_size = style.get(\"chunk\", 1)\n        font_size = 54\n        bord_w = 8\n        shad_w = 4\n        margin_v = 240  # Posición Y ≈ 840px (arriba de la onda horizontal de 940px)\n        \n        ass_lines = [\n            \"[Script Info]\",\n            \"ScriptType: v4.00+\",\n            \"PlayResX: 1920\",\n            \"PlayResY: 1080\",\n            \"\",\n            \"[V4+ Styles]\",\n            \"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\",\n            f\"Style: Default,DejaVu Sans,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,{bord_w},{shad_w},2,60,60,{margin_v},1\",\n            \"\",\n            \"[Events]\",\n            \"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\"\n        ]\n        \n        for segment in segments:\n            words = list(segment.words)\n            if not words:\n                start = format_ass_time(segment.start)\n                end = format_ass_time(segment.end)\n                txt = segment.text.strip().upper()\n                styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{txt}\"\n                ass_lines.append(f\"Dialogue: 0,{start},{end},Default,,0,0,0,,{styled_text}\")\n                continue\n                \n            for i in range(0, len(words), chunk_size):\n                chunk = words[i:i + chunk_size]\n                chunk_start = format_ass_time(chunk[0].start)\n                chunk_end = format_ass_time(chunk[-1].end)\n                \n                if style.get(\"bicolor\") and len(chunk) > 1:\n                    w1 = chunk[0].word.strip().upper()\n                    w2 = \" \".join([w.word.strip().upper() for w in chunk[1:]])\n                    styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w1} {{\\\\1c{style['c2']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w2}\"\n                else:\n                    w_text = \" \".join([w.word.strip().upper() for w in chunk])\n                    styled_text = f\"{{\\\\b1\\\\fs{font_size}\\\\1c{style['c1']}\\\\3c&H000000&\\\\4c&H000000&\\\\bord{bord_w}\\\\shad{shad_w}}}{w_text}\"\n                \n                ass_lines.append(f\"Dialogue: 0,{chunk_start},{chunk_end},Default,,0,0,0,,{styled_text}\")\n                \n        with open(str(ass_path), \"w\", encoding=\"utf-8\") as f:\n            f.write(\"\\n\".join(ass_lines))\n        return True\n    except Exception as e:\n        print(f\"⚠️ Error generando subtítulos 16:9: {e}\", flush=True)\n        return False\n\n# =========================================================================\n# 🎬 MOTOR CINEMA 16:9 FULL HD (MULTI-IMAGEN ORDENADA + FUNDIDO SUAVE + WHISPER)\n# =========================================================================\nasync def render_cinema_169_full(job, ws, job_dir):\n    job_id = job.get(\"id\")\n    title = job.get(\"title\", \"Cinema 16:9\")\n    crossfade_dur = float(job.get(\"crossfadeDuration\", 1.5))\n    waveform_key = job.get(\"waveformStyle\", \"auto\")\n    caption_style_key = job.get(\"captionStyle\", \"auto\")\n\n    if waveform_key == \"auto\" or not waveform_key:\n        waveform_key = \"spotify_horizontal\"\n    if caption_style_key == \"auto\" or not caption_style_key:\n        caption_style_key = \"hormozi_1w_yellow\"\n\n    print(f\"\\n{'='*65}\", flush=True)\n    print(f\"🎬 [MOTOR CINEMA 16:9] Generando Video Panorámico 1920x1080\", flush=True)\n    print(f\"📋 Título: {title} | Transición: {crossfade_dur}s | Onda: {waveform_key} | Subs: {caption_style_key}\", flush=True)\n    print(f\"{'='*65}\\n\", flush=True)\n\n    await ws.send(json.dumps({\n        \"type\": \"JOB_PROGRESS\",\n        \"jobId\": job_id,\n        \"percent\": 10,\n        \"stage\": \"SINCRONIZANDO RECURSOS\",\n        \"message\": \"📥 Descargando audio y secuencia de imágenes 16:9...\"\n    }))\n\n    audio_files = []\n    image_files = []\n\n    # 1. Descarga desde Google Drive si viene link\n    drive_audio = job.get(\"driveAudiosUrl\") or job.get(\"driveAudioUrl\")\n    drive_images = job.get(\"driveImagesUrl\") or job.get(\"driveCoversUrl\")\n\n    if drive_audio and \"drive.google.com\" in str(drive_audio):\n        a_dir = job_dir / \"gdrive_audio\"\n        a_dir.mkdir(exist_ok=True)\n        import gdown\n        try:\n            gdown.download_folder(url=drive_audio, output=str(a_dir), quiet=False, use_cookies=False)\n        except Exception:\n            gdown.download(url=drive_audio, output=str(a_dir / \"soundtrack.mp3\"), quiet=False, fuzzy=True)\n        for ext in [\"*.mp3\", \"*.wav\", \"*.ogg\", \"*.flac\", \"*.m4a\"]:\n            audio_files.extend(list(a_dir.glob(ext)))\n\n    if drive_images and \"drive.google.com\" in str(drive_images):\n        i_dir = job_dir / \"gdrive_images\"\n        i_dir.mkdir(exist_ok=True)\n        import gdown\n        try:\n            gdown.download_folder(url=drive_images, output=str(i_dir), quiet=False, use_cookies=False)\n        except Exception:\n            gdown.download(url=drive_images, output=str(i_dir / \"photo.jpg\"), quiet=False, fuzzy=True)\n        for ext in [\"*.png\", \"*.jpg\", \"*.jpeg\", \"*.webp\"]:\n            image_files.extend(list(i_dir.glob(ext)))\n\n    # 2. Descarga desde servidor Master si vienen subidas\n    if not audio_files and job.get(\"audioUrl\"):\n        local_a = job_dir / \"soundtrack.mp3\"\n        if download_file_robust(job.get(\"audioUrl\"), local_a):\n            audio_files.append(local_a)\n\n    if not image_files:\n        raw_imgs = job.get(\"imageUrls\") or job.get(\"coverUrls\") or []\n        for idx, r_img in enumerate(raw_imgs):\n            local_i = job_dir / f\"frame_{idx+1:03d}.png\"\n            if download_file_robust(r_img, local_i):\n                image_files.append(local_i)\n\n    if not audio_files:\n        raise ValueError(\"No se encontró archivo de audio para el proyecto Cinema 16:9\")\n    if not image_files:\n        raise ValueError(\"No se encontraron imágenes para el proyecto Cinema 16:9\")\n\n    # 3. ORDENAMIENTO NATURAL ESTRICTO (01.jpg, 02.png, ..., 10.jpg)\n    image_files.sort(key=lambda p: natural_sort_key(p.name))\n    print(f\"🖼️ Secuencia de {len(image_files)} imágenes ordenadas:\", flush=True)\n    for idx, img_p in enumerate(image_files):\n        print(f\"   [{idx+1:02d}] {img_p.name}\", flush=True)\n\n    soundtrack = audio_files[0]\n    total_dur = get_media_duration(soundtrack)\n    if total_dur <= 0: total_dur = 180.0\n    print(f\"🎵 Duración Total Soundtrack: {total_dur:.2f}s\", flush=True)\n\n    # 4. Transcripción y Subtítulos 16:9 con Faster-Whisper\n    ass_path = None\n    if caption_style_key != \"none\":\n        await ws.send(json.dumps({\n            \"type\": \"JOB_PROGRESS\",\n            \"jobId\": job_id,\n            \"percent\": 25,\n            \"stage\": \"WHISPER SUBTITLES IA\",\n            \"message\": \"🎙️ Transcribiendo audio completo y generando subtítulos 16:9...\"\n        }))\n        ass_path = job_dir / \"cinema_169_subs.ass\"\n        transcribe_audio_to_ass_169(soundtrack, ass_path, style_key=caption_style_key)\n\n    # 5. CONSTRUIR FILTRO FFMPEG CROSSFADE MULTI-IMAGEN\n    await ws.send(json.dumps({\n        \"type\": \"JOB_PROGRESS\",\n        \"jobId\": job_id,\n        \"percent\": 45,\n        \"stage\": \"FFMPEG GPU RENDER\",\n        \"message\": \"🎬 Renderizando secuencia multi-imagen con fundido cinemático y onda panorámica...\"\n    }))\n\n    N = len(image_files)\n    time_per_image = total_dur / N\n\n    # Ajustar duración de crossfade si el tiempo por imagen es corto\n    actual_xfade = min(crossfade_dur, max(0.2, time_per_image * 0.4))\n    if actual_xfade <= 0.05 or N == 1:\n        actual_xfade = 0.0\n\n    # Armar comando FFmpeg con entradas de video\n    inputs = []\n    filter_complex_parts = []\n\n    # Cada imagen como input\n    for i, img_path in enumerate(image_files):\n        inputs.extend([\"-loop\", \"1\", \"-t\", f\"{total_dur + 5.0:.2f}\", \"-i\", str(img_path)])\n        # Normalizar a 1920x1080 30fps\n        filter_complex_parts.append(\n            f\"[{i}:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,setsar=1,fps=30,format=yuv420p[v{i}]\"\n        )\n\n    # Audio input al final de los inputs\n    audio_input_idx = N\n    inputs.extend([\"-i\", str(soundtrack)])\n\n    # Cadena de Crossfades\n    if N == 1 or actual_xfade <= 0:\n        filter_complex_parts.append(f\"[v0]null[slideshow]\")\n    else:\n        last_v = \"v0\"\n        for k in range(N - 1):\n            offset = (k + 1) * time_per_image - actual_xfade\n            out_v = f\"xf{k+1}\" if (k < N - 2) else \"slideshow\"\n            filter_complex_parts.append(\n                f\"[{last_v}][v{k+1}]xfade=transition=fade:duration={actual_xfade:.2f}:offset={offset:.2f}[{out_v}]\"\n            )\n            last_v = out_v\n\n    # Onda Panorámica 16:9\n    wave_cfg = PANORAMIC_WAVEFORMS_169.get(waveform_key, PANORAMIC_WAVEFORMS_169[\"spotify_horizontal\"])\n    if waveform_key != \"none\":\n        filter_complex_parts.append(f\"[{audio_input_idx}:a]{wave_cfg['filter']}\")\n        filter_complex_parts.append(f\"[slideshow][wave]overlay=(W-w)/2:{wave_cfg['y']}[v_with_wave]\")\n        final_video_label = \"v_with_wave\"\n    else:\n        final_video_label = \"slideshow\"\n\n    # Burn Subtítulos ASS 16:9\n    if ass_path and os.path.exists(str(ass_path)):\n        escaped_ass = str(ass_path).replace(\":\", \"\\\\:\").replace(\"\\\\\", \"/\")\n        filter_complex_parts.append(f\"[{final_video_label}]ass='{escaped_ass}'[final_v]\")\n        map_out_v = \"[final_v]\"\n    else:\n        map_out_v = f\"[{final_video_label}]\"\n\n    final_filter_str = \"; \".join(filter_complex_parts)\n    out_video = job_dir / f\"cinema_169_{title.replace(' ', '_')}.mp4\"\n\n    cmd = [\n        \"ffmpeg\", \"-y\"\n    ] + inputs + [\n        \"-filter_complex\", final_filter_str,\n        \"-map\", map_out_v,\n        \"-map\", f\"{audio_input_idx}:a\",\n        \"-c:v\", \"libx264\", \"-preset\", \"veryfast\", \"-crf\", \"18\", \"-profile:v\", \"high\", \"-pix_fmt\", \"yuv420p\",\n        \"-c:a\", \"aac\", \"-b:a\", \"320k\", \"-ar\", \"48000\",\n        \"-t\", f\"{total_dur:.2f}\",\n        \"-movflags\", \"+faststart\",\n        str(out_video)\n    ]\n\n    print(f\"🚀 Ejecutando Renderizado Cinemático FFmpeg...\", flush=True)\n    res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n    if res.returncode != 0:\n        print(f\"❌ FFmpeg Error: {res.stderr.decode('utf-8', errors='ignore')}\", flush=True)\n        raise RuntimeError(f\"Error en renderizado FFmpeg Cinema 16:9\")\n\n    # 6. Subir Video Final al Master Server\n    await ws.send(json.dumps({\n        \"type\": \"JOB_PROGRESS\",\n        \"jobId\": job_id,\n        \"percent\": 88,\n        \"stage\": \"SUBIENDO AL MASTER\",\n        \"message\": \"⬆️ Subiendo Video Cinema 16:9 Full HD al servidor...\"\n    }))\n\n    print(f\"⬆️ Subiendo video final 16:9 ({out_video.name}) al Master...\", flush=True)\n    up_ok = upload_single_video_robust(job_id, out_video, is_final=True, max_retries=4)\n    if not up_ok:\n        print(\"⚠️ Advertencia: Falló subida directa tras reintentos.\", flush=True)\n\n    await ws.send(json.dumps({\n        \"type\": \"JOB_COMPLETED\",\n        \"jobId\": job_id,\n        \"result\": {\n            \"success\": True,\n            \"videosCount\": 1,\n            \"aspectRatio\": \"16:9\",\n            \"message\": f\"🎉 ¡Video Cinema 16:9 Full HD completado y subido con éxito!\"\n        }\n    }))\n    print(f\"🎉 ¡PROYECTO CINEMA 16:9 {job_id} COMPLETADO AL 100%!\\n\", flush=True)\n\n\n# =========================================================================\n# 📱 MOTOR 9:16 VERTICAL BATCH (FRASES INTELIGENTES + SUBTÍTULOS CINTURA)\n# =========================================================================\ndef render_single_video_variant(audio_path, cover_path, out_video_path, ass_path, duration=30, variant_idx=0, dynamic_wave_y=None):\n    try:\n        wave = MODERN_WAVEFORMS_916[variant_idx % len(MODERN_WAVEFORMS_916)]\n        final_wave_y = dynamic_wave_y if dynamic_wave_y is not None else wave.get(\"base_y\", 1420)\n        \n        zoom_effects = [\n            \"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920:(in_w-1080)/2:(in_h-1920)/2,zoompan=z='min(1.0+0.00006*in,1.035)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=1080x1920:fps=30\",\n            \"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920:(in_w-1080)/2:(in_h-1920)/2,zoompan=z='max(1.035-0.00006*in,1.00)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=1080x1920:fps=30\",\n            \"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920:(in_w-1080)/2:(in_h-1920)/2,zoompan=z='1.012+0.012*sin(2*3.14159*in/360)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=1080x1920:fps=30\",\n            \"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920:(in_w-1080)/2:(in_h-1920)/2,zoompan=z='min(1.0+0.00005*in,1.028)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=1080x1920:fps=30\"\n        ]\n        zoom_filter = zoom_effects[variant_idx % len(zoom_effects)]\n        \n        filter_complex = f\"[0:v]{zoom_filter}[bg]; {wave['filter']}; [bg][wave]overlay=(W-w)/2:{final_wave_y}[v_wave]\"\n        \n        if ass_path and os.path.exists(str(ass_path)):\n            escaped_ass = str(ass_path).replace(\":\", \"\\\\:\").replace(\"\\\\\", \"/\")\n            filter_complex += f\"; [v_wave]ass='{escaped_ass}'[v_out]\"\n            map_v = \"[v_out]\"\n        else:\n            map_v = \"[v_wave]\"\n            \n        dur_arg = [\"-t\", str(duration)] if duration and float(duration) > 0 else []\n        \n        cmd = [\n            \"ffmpeg\", \"-y\",\n            \"-loop\", \"1\", \"-i\", str(cover_path),\n            \"-i\", str(audio_path),\n            \"-filter_complex\", filter_complex,\n            \"-map\", map_v,\n            \"-map\", \"1:a\",\n            \"-c:v\", \"libx264\", \"-preset\", \"veryfast\", \"-crf\", \"18\", \"-profile:v\", \"high\", \"-level\", \"4.2\", \"-pix_fmt\", \"yuv420p\", \"-movflags\", \"+faststart\", \"-sws_flags\", \"lanczos\",\n            \"-c:a\", \"aac\", \"-b:a\", \"320k\", \"-ar\", \"48000\", \"-shortest\"\n        ] + dur_arg + [str(out_video_path)]\n        \n        res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n        return res.returncode == 0 and os.path.exists(str(out_video_path))\n    except Exception as e:\n        print(f\"⚠️ Error renderizando video variant 9:16: {e}\", flush=True)\n        return False\n\nasync def render_voxforja_916_batch(job, ws, job_dir):\n    job_id = job.get(\"id\")\n    title = job.get(\"title\", \"Lote 9:16\")\n    raw_dur = job.get(\"duration\", \"auto\")\n    variants_count = int(job.get(\"variantsCount\", 10))\n\n    audio_files = []\n    image_files = []\n\n    # 1. Drive Sync\n    drive_audio = job.get(\"driveAudiosUrl\") or job.get(\"driveAudioUrl\")\n    drive_images = job.get(\"driveCoversUrl\") or job.get(\"driveImagesUrl\")\n\n    if drive_audio and \"drive.google.com\" in str(drive_audio):\n        a_dir = job_dir / \"gdrive_audio\"\n        a_dir.mkdir(exist_ok=True)\n        import gdown\n        try:\n            gdown.download_folder(url=drive_audio, output=str(a_dir), quiet=False, use_cookies=False)\n        except Exception:\n            gdown.download(url=drive_audio, output=str(a_dir / \"audio_source.mp3\"), quiet=False, fuzzy=True)\n        for ext in [\"*.mp3\", \"*.wav\", \"*.ogg\", \"*.flac\", \"*.m4a\"]:\n            audio_files.extend(list(a_dir.glob(ext)))\n\n    if drive_images and \"drive.google.com\" in str(drive_images):\n        img_dir = job_dir / \"gdrive_images\"\n        img_dir.mkdir(exist_ok=True)\n        import gdown\n        try:\n            gdown.download_folder(url=drive_images, output=str(img_dir), quiet=False, use_cookies=False)\n        except Exception:\n            gdown.download(url=drive_images, output=str(img_dir / \"cover_source.png\"), quiet=False, fuzzy=True)\n        for ext in [\"*.png\", \"*.jpg\", \"*.jpeg\", \"*.webp\"]:\n            image_files.extend(list(img_dir.glob(ext)))\n\n    # 2. Master Server Uploads\n    if not audio_files:\n        raw_audios = []\n        if job.get(\"audioUrl\"): raw_audios.append(job.get(\"audioUrl\"))\n        if job.get(\"audioUrls\") and isinstance(job.get(\"audioUrls\"), list): raw_audios.extend(job.get(\"audioUrls\"))\n        for idx, r_aud in enumerate(raw_audios):\n            local_a = job_dir / f\"master_audio_{idx+1}.wav\"\n            if download_file_robust(r_aud, local_a):\n                audio_files.append(local_a)\n\n    if not image_files:\n        raw_covers = []\n        if job.get(\"coverUrl\"): raw_covers.append(job.get(\"coverUrl\"))\n        if job.get(\"coverUrls\") and isinstance(job.get(\"coverUrls\"), list): raw_covers.extend(job.get(\"coverUrls\"))\n        for idx, r_cov in enumerate(raw_covers):\n            local_c = job_dir / f\"master_cover_{idx+1}.png\"\n            if download_file_robust(r_cov, local_c):\n                image_files.append(local_c)\n\n    if not audio_files or not image_files:\n        raise ValueError(f\"No se encontraron archivos válidos (Audios: {len(audio_files)}, Fotos: {len(image_files)})\")\n\n    phrase_blocks = extract_smart_phrase_segments(audio_files[0], variants_count=variants_count, requested_duration=raw_dur)\n\n    produced_videos = []\n    step_pct = 80.0 / variants_count\n\n    for i in range(variants_count):\n        if job_id in CURRENT_CANCELLED_JOBS:\n            print(f\"🛑 Trabajo {job_id} cancelado.\", flush=True)\n            return\n\n        curr_img = image_files[i % len(image_files)]\n        curr_audio = audio_files[i % len(audio_files)]\n        block = phrase_blocks[i % len(phrase_blocks)]\n        \n        layout_info = analyze_smart_layout(curr_img)\n        dynamic_wave_y = layout_info.get(\"wave_y\", 1420)\n        dynamic_sub_margin_v = layout_info.get(\"sub_margin_v\", 900)\n        \n        style_info = VIRAL_CAPTION_STYLES[i % len(VIRAL_CAPTION_STYLES)]\n        wave_info = MODERN_WAVEFORMS_916[i % len(MODERN_WAVEFORMS_916)]\n        \n        print(f\"\\n🎬 [{i+1}/{variants_count}] Renderizando: {block['label']} | Subtítulos CINTURA '{style_info['name']}' + Onda '{wave_info['name']}'...\", flush=True)\n\n        seg_audio = job_dir / f\"seg_{i+1}.mp3\"\n        extract_audio_segment(curr_audio, seg_audio, block[\"start\"], block[\"duration\"])\n\n        seg_ass = job_dir / f\"sub_{i+1}.ass\"\n        transcribe_audio_to_ass(seg_audio, seg_ass, style_idx=i, sub_margin_v=dynamic_sub_margin_v)\n\n        out_vid = job_dir / f\"variant_{i+1}_{title.replace(' ', '_')}.mp4\"\n        success = render_single_video_variant(\n            audio_path=seg_audio,\n            cover_path=curr_img,\n            out_video_path=out_vid,\n            ass_path=seg_ass,\n            duration=block[\"duration\"],\n            variant_idx=i,\n            dynamic_wave_y=dynamic_wave_y\n        )\n\n        if success and out_vid.exists() and out_vid.stat().st_size > 0:\n            is_last = (i == variants_count - 1)\n            print(f\"  ⬆️ Subiendo variante [{i+1}/{variants_count}] al servidor...\", flush=True)\n            up_ok = upload_single_video_robust(job_id, out_vid, is_final=is_last, max_retries=4)\n            if up_ok:\n                print(f\"  ✅ [Variante {i+1}/{variants_count}] Subida completada ({out_vid.name})\", flush=True)\n                produced_videos.append(str(out_vid))\n            else:\n                print(f\"  ⚠️ [Variante {i+1}/{variants_count}] Falló la subida.\", flush=True)\n                produced_videos.append(str(out_vid))\n        else:\n            print(f\"  ❌ [Variante {i+1}/{variants_count}] Falló el renderizado FFmpeg.\", flush=True)\n\n        curr_progress = int(10 + (i + 1) * step_pct)\n        await ws.send(json.dumps({\n            \"type\": \"JOB_PROGRESS\",\n            \"jobId\": job_id,\n            \"progress\": min(98, curr_progress),\n            \"percent\": min(98, curr_progress),\n            \"message\": f\"🎬 Variante {i+1}/{variants_count} completada ({block['duration']}s)\"\n        }))\n\n    await ws.send(json.dumps({\n        \"type\": \"JOB_COMPLETED\",\n        \"jobId\": job_id,\n        \"result\": {\n            \"success\": True,\n            \"videosCount\": len(produced_videos),\n            \"message\": f\"🎉 ¡Completadas {len(produced_videos)} variantes con Subtítulos en Cintura!\"\n        }\n    }))\n    print(f\"\\n🎉 ¡TRABAJO 9:16 {job_id} COMPLETADO! Subidas {len(produced_videos)} variantes.\\n\", flush=True)\n\nasync def render_speech_generation(job, ws, job_dir):\n    job_id = job.get(\"id\")\n    title = job.get(\"title\", \"Locución IA\")\n    script = job.get(\"script\", \"\")\n    language = str(job.get(\"language\", \"es\")).strip().lower() or \"es\"\n    artist_name = job.get(\"artistName\", \"Relator\")\n    voice_sample_url = job.get(\"voiceSampleUrl\")\n    auto_video = job.get(\"autoVideo\", False)\n    social_handle = job.get(\"socialHandle\", \"@wentixai\")\n    cta_text = job.get(\"ctaText\", \"\")\n\n    print(f\"\\n🎙️ [VOICE STUDIO] Iniciando síntesis de voz en [{language.upper()}] para tarea {job_id}...\", flush=True)\n    print(f\"   👤 Voz / Relator: {artist_name}\", flush=True)\n    print(f\"   🌐 Idioma Seleccionado: {language.upper()}\", flush=True)\n    print(f\"   📝 Guion ({len(script)} caracteres): \\\"{script[:80]}...\\\"\", flush=True)\n\n    await ws.send(json.dumps({\n        \"type\": \"JOB_PROGRESS\",\n        \"jobId\": job_id,\n        \"percent\": 15,\n        \"stage\": \"SÍNTESIS VOCAL\",\n        \"message\": f\"🎙️ Preparando clonación y síntesis en {language.upper()} para {artist_name}...\"\n    }))\n\n    out_audio_path = job_dir / f\"speech_raw_{job_id[:8]}.wav\"\n    out_mp3_path = job_dir / f\"speech_{job_id[:8]}.mp3\"\n    cloned_success = False\n\n    # 1. INTENTO DE CLONACIÓN ZERO-SHOT (XTTS-v2 / F5-TTS) SI HAY MUESTRA VOCAL\n    if voice_sample_url:\n        sample_path = job_dir / \"voice_reference.mp3\"\n        sample_wav = job_dir / \"voice_reference_clean.wav\"\n        try:\n            full_sample_url = normalize_asset_url(voice_sample_url)\n            print(f\"📥 Descargando muestra de referencia vocal desde {full_sample_url}...\", flush=True)\n            if download_file_robust(full_sample_url, sample_path):\n                # Normalizar audio a WAV mono 24000Hz PCM 16-bit (estándar óptimo de XTTS-v2)\n                subprocess.run([\n                    \"ffmpeg\", \"-y\", \"-i\", str(sample_path),\n                    \"-ar\", \"24000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", str(sample_wav)\n                ], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n\n                if sample_wav.exists() and sample_wav.stat().st_size > 1000:\n                    print(f\"🧬 Clonando voz de '{artist_name}' en idioma '{language}'...\", flush=True)\n                    await ws.send(json.dumps({\n                        \"type\": \"JOB_PROGRESS\",\n                        \"jobId\": job_id,\n                        \"percent\": 35,\n                        \"stage\": \"CLONANDO VOZ IA\",\n                        \"message\": f\"🧬 Clonando timbre vocal exacto de {artist_name} en {language.upper()}...\"\n                    }))\n\n                    cloner = get_voice_cloner()\n                    supported_langs = [\"es\", \"en\", \"pt\", \"fr\", \"it\", \"de\", \"pl\", \"tr\", \"ru\", \"nl\", \"cs\", \"ar\", \"zh-cn\", \"ja\", \"hu\", \"ko\", \"hi\"]\n                    xtts_lang = language if language in supported_langs else \"es\"\n                    print(f\"⚡ Ejecutando Coqui XTTS-v2 en GPU (idioma={xtts_lang}, muestra={sample_wav.name})...\", flush=True)\n                    try:\n                        cloner.tts_to_file(\n                            text=script,\n                            speaker_wav=str(sample_wav),\n                            language=xtts_lang,\n                            file_path=str(out_audio_path),\n                            split_sentences=True\n                        )\n                    except Exception as e_tts_split:\n                        print(f\"ℹ️ Reintentando sin split_sentences: {e_tts_split}\", flush=True)\n                        cloner.tts_to_file(\n                            text=script,\n                            speaker_wav=str(sample_wav),\n                            language=xtts_lang,\n                            file_path=str(out_audio_path),\n                            split_sentences=False\n                        )\n\n                    if out_audio_path.exists() and out_audio_path.stat().st_size > 1000:\n                        # Convertir a MP3 a 320kbps\n                        subprocess.run([\n                            \"ffmpeg\", \"-y\", \"-i\", str(out_audio_path),\n                            \"-c:a\", \"libmp3lame\", \"-b:a\", \"320k\", str(out_mp3_path)\n                        ], stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n                        if out_mp3_path.exists() and out_mp3_path.stat().st_size > 1000:\n                            cloned_success = True\n                            print(f\"✅ ¡Clonación vocal auténtica completada en {language.upper()} para {artist_name}!\", flush=True)\n        except Exception as clone_err:\n            import traceback\n            traceback.print_exc()\n            print(f\"❌ Error durante clonación XTTS-v2: {clone_err}\", flush=True)\n            raise RuntimeError(f\"Error en clonación vocal XTTS-v2: {clone_err}\")\n\n    # 2. SÍNTESIS NEURONAL SOLO SI NO SE PROPORCIONÓ MUESTRA VOCAL\n    if not voice_sample_url:\n        print(f\"⚡ Sintetizando con motor neuronal multilingüe ({language})...\", flush=True)\n        try:\n            import edge_tts\n            lang_voice_map = {\n                \"es\": \"es-CL-LorenzoNeural\",\n                \"en\": \"en-US-AndrewNeural\",\n                \"pt\": \"pt-BR-AntonioNeural\",\n                \"fr\": \"fr-FR-HenriNeural\",\n                \"it\": \"it-IT-DiegoNeural\",\n                \"de\": \"de-DE-ConradNeural\",\n                \"ja\": \"ja-JP-KeitaNeural\",\n                \"zh\": \"zh-CN-YunxiNeural\",\n                \"ar\": \"ar-SA-HamedNeural\",\n                \"hi\": \"hi-IN-MadhurNeural\",\n                \"ru\": \"ru-RU-DmitryNeural\",\n                \"nl\": \"nl-NL-MaartenNeural\",\n                \"pl\": \"pl-PL-MarekNeural\",\n                \"tr\": \"tr-TR-AhmetNeural\",\n                \"ko\": \"ko-KR-InJoonNeural\"\n            }\n            chosen_voice = lang_voice_map.get(language, \"es-CL-LorenzoNeural\")\n            lower_name = artist_name.lower()\n            if language == \"es\":\n                if \"lissa\" in lower_name or \"mujer\" in lower_name or \"femenin\" in lower_name:\n                    chosen_voice = \"es-MX-DaliaNeural\"\n                elif \"ella\" in lower_name:\n                    chosen_voice = \"es-ES-ElviraNeural\"\n                elif \"vale\" in lower_name:\n                    chosen_voice = \"es-CL-CatalinaNeural\"\n                elif \"marcus\" in lower_name:\n                    chosen_voice = \"es-ES-AlvaroNeural\"\n\n            communicate = edge_tts.Communicate(script, chosen_voice, rate=\"+0%\", pitch=\"+0Hz\")\n            await communicate.save(str(out_mp3_path))\n        except Exception as tts_err:\n            print(f\"⚠️ EdgeTTS fallback: {tts_err}, usando gTTS...\", flush=True)\n            from gtts import gTTS\n            tts = gTTS(text=script, lang=language if language in ['es','en','fr','pt','de','it','ja','ru','hi','ar'] else 'es', slow=False)\n            tts.save(str(out_mp3_path))\n\n    final_audio_path = out_mp3_path if (out_mp3_path.exists() and out_mp3_path.stat().st_size > 1000) else out_audio_path\n    print(f\"✅ Audio final generado: {final_audio_path.name}\", flush=True)\n\n    if not auto_video:\n        # Modo 1: Solo Audio / Locución Clonada (MP3)\n        await ws.send(json.dumps({\n            \"type\": \"JOB_PROGRESS\",\n            \"jobId\": job_id,\n            \"percent\": 85,\n            \"stage\": \"SUBIENDO AUDIO\",\n            \"message\": \"⬆️ Subiendo locución final al servidor...\"\n        }))\n\n        with open(final_audio_path, \"rb\") as f_aud:\n            files = {\"audio\": (final_audio_path.name, f_aud, \"audio/mpeg\")}\n            data = {\"jobId\": job_id, \"isFinal\": \"true\"}\n            r_up = requests.post(f\"{MASTER_HTTP_URL}/api/worker/upload-result\", files=files, data=data, timeout=60, verify=False)\n            print(f\"⬆️ Respuesta de subida audio: {r_up.status_code}\", flush=True)\n\n        await ws.send(json.dumps({\n            \"type\": \"JOB_COMPLETED\",\n            \"jobId\": job_id,\n            \"result\": {\n                \"success\": True,\n                \"audioUrl\": f\"/storage/audios/{final_audio_path.name}\",\n                \"message\": f\"🎉 ¡Locución de voz ({language.upper()}) para {artist_name} completada con éxito!\"\n            }\n        }))\n        print(f\"🎉 ¡TAREA DE VOZ {job_id} FINALIZADA AL 100%!\\n\", flush=True)\n    else:\n        # Modo 2: Video 9:16 con Subtítulos Whisper IA\n        await ws.send(json.dumps({\n            \"type\": \"JOB_PROGRESS\",\n            \"jobId\": job_id,\n            \"percent\": 60,\n            \"stage\": \"WHISPER SUBTITLES IA\",\n            \"message\": \"🎙️ Transcribiendo audio y generando subtítulos virales 9:16...\"\n        }))\n\n        ass_path = job_dir / \"speech_subs.ass\"\n        transcribe_audio_to_ass(final_audio_path, ass_path, style_index=0)\n\n        cover_path = job_dir / \"cover_speech.png\"\n        create_default_gradient_cover(cover_path, title=artist_name, subtitle=social_handle)\n\n        out_video_path = job_dir / f\"speech_video_{job_id[:8]}.mp4\"\n        audio_dur = get_media_duration(final_audio_path)\n\n        render_single_video_variant(\n            audio_path=final_audio_path,\n            cover_path=cover_path,\n            out_video_path=out_video_path,\n            ass_path=ass_path,\n            duration=audio_dur,\n            variant_idx=0\n        )\n\n        upload_single_video_robust(job_id, out_video_path, is_final=True)\n\n        await ws.send(json.dumps({\n            \"type\": \"JOB_COMPLETED\",\n            \"jobId\": job_id,\n            \"result\": {\n                \"success\": True,\n                \"videosCount\": 1,\n                \"message\": f\"🎉 ¡Video 9:16 con voz de {artist_name} y subtítulos completado!\"\n            }\n        }))\n        print(f\"🎉 ¡VIDEO 9:16 CON VOZ {job_id} COMPLETADO AL 100%!\\n\", flush=True)\n\n# Dispatcher General\nasync def process_job(job, ws):\n    job_id = job.get(\"id\")\n    job_type = job.get(\"type\", \"VIDEO_GENERATION\")\n    aspect_ratio = job.get(\"aspectRatio\", \"9:16\")\n\n    job_dir = Path(f\"/tmp/voxforja_jobs/{job_id}\")\n    if job_dir.exists(): shutil.rmtree(job_dir, ignore_errors=True)\n    job_dir.mkdir(parents=True, exist_ok=True)\n\n    if job_type == \"SPEECH_GENERATION\":\n        await render_speech_generation(job, ws, job_dir)\n    elif job_type == \"VIDEO_16_9\" or aspect_ratio == \"16:9\":\n        await render_cinema_169_full(job, ws, job_dir)\n    else:\n        await render_voxforja_916_batch(job, ws, job_dir)\n\nasync def worker_loop():\n    while True:\n        try:\n            print(f\"🔗 Conectando Worker GPU a {MASTER_WS_URL}...\", flush=True)\n            async with websockets.connect(MASTER_WS_URL, ssl=ssl_context, max_size=500*1024*1024) as ws:\n                print(f\"\\n{'='*65}\", flush=True)\n                print(f\"✅ ¡WORKER GPU CONECTADO Y AUTENTICADO!\", flush=True)\n                print(f\"🔥 GPU: {GPU_NAME} | VRAM: {GPU_VRAM}\", flush=True)\n                print(f\"⚡ Motores Activos: VOXFORJA 9:16 VERTICAL + CINEMA 16:9 FULL HD\", flush=True)\n                print(f\"🎯 Estado: LISTO PARA RECIBIR TAREAS EN VIVO\", flush=True)\n                print(f\"{'='*65}\\n\", flush=True)\n\n                await ws.send(json.dumps({\n                    \"type\": \"WORKER_REGISTER\",\n                    \"gpuName\": GPU_NAME,\n                    \"gpuVram\": GPU_VRAM,\n                    \"supportedEngines\": [\"VOXFORJA_9_16\", \"CINEMA_16_9\", \"VOICE_STUDIO\", \"SMART_PHRASING\", \"WAIST_SUBTITLES\"]\n                }))\n\n                async for msg_str in ws:\n                    try:\n                        msg = json.loads(msg_str)\n                        mtype = msg.get(\"type\")\n                        if mtype in [\"DISPATCH_JOB\", \"NEW_JOB\", \"JOB_DISPATCH\"]:\n                            job = msg.get(\"job\", {})\n                            jid = job.get(\"id\")\n                            if jid and jid not in ACTIVE_RUNNING_JOBS:\n                                ACTIVE_RUNNING_JOBS.add(jid)\n                                asyncio.create_task(process_job_safe(job, ws))\n                        elif mtype == \"CANCEL_JOB\":\n                            c_id = msg.get(\"jobId\")\n                            if c_id: CURRENT_CANCELLED_JOBS.add(c_id)\n                    except Exception as parse_err:\n                        print(f\"⚠️ Error parseando mensaje: {parse_err}\", flush=True)\n        except Exception as conn_err:\n            print(f\"⚠️ Desconectado del Master: {conn_err}. Reintentando en 4s...\", flush=True)\n            await asyncio.sleep(4)\n\nasync def process_job_safe(job, ws):\n    jid = job.get(\"id\")\n    try:\n        await process_job(job, ws)\n    except Exception as e:\n        print(f\"❌ Error procesando trabajo {jid}: {e}\", flush=True)\n        await ws.send(json.dumps({\n            \"type\": \"JOB_FAILED\",\n            \"jobId\": jid,\n            \"error\": str(e)\n        }))\n    finally:\n        ACTIVE_RUNNING_JOBS.discard(jid)\n\n# Iniciar bucle del worker en segundo plano\nloop = asyncio.get_event_loop()\nloop.run_until_complete(worker_loop())\n"
      ]
    }
  ]
}