"""Pegar en una celda del notebook ya abierto: repara pesos y actualiza el cliente."""
from pathlib import Path
import os, subprocess, sys
ROOT = Path('/kaggle/working/humana')
GPU = ROOT / 'gpu'
VENDOR = ROOT / 'MuseTalk'
PYTHON = ROOT / 'env-musetalk/bin/python'
if not PYTHON.is_file() or not VENDOR.is_dir():
    raise FileNotFoundError('Este parche usa la sesión ya instalada. Para una sesión nueva ejecuta el notebook 01 actualizado.')
GPU.mkdir(exist_ok=True)
PAYLOAD = {'model_files.py': '"""Archivos reales de modelos y reparación local de enlaces de la caché HF."""\nimport argparse\nimport json\nimport os\nfrom pathlib import Path\nimport shutil\nimport tempfile\n\n\ndef manifest(engine=\'musetalk\'):\n    return json.loads(Path(__file__).with_name(\'models.json\').read_text())[engine]\n\n\ndef materialize(source, destination):\n    # hf_hub_download devuelve normalmente un enlace relativo en snapshots/.\n    # En Linux os.link puede copiar ese enlace, cuya ruta se rompe en models/.\n    source = Path(source).resolve(strict=True)\n    destination = Path(destination)\n    if not source.is_file() or source.stat().st_size == 0:\n        raise FileNotFoundError(f\'El modelo de origen está vacío o no existe: {source}\')\n    destination.parent.mkdir(parents=True, exist_ok=True)\n    if destination.is_file() and not destination.is_symlink() and destination.stat().st_size == source.stat().st_size:\n        return\n    with tempfile.TemporaryDirectory(prefix=\'.model_\', dir=destination.parent) as folder:\n        temporary = Path(folder) / destination.name\n        try:\n            os.link(source, temporary)\n        except OSError:\n            shutil.copyfile(source, temporary)\n        temporary.replace(destination)\n\n\ndef check_models(vendor, engine=\'musetalk\'):\n    vendor = Path(vendor)\n    required = [vendor / \'models\' / item[\'destination\'] for item in manifest(engine)]\n    if engine == \'musetalk\':\n        required.append(vendor / \'musetalk/utils/face_detection/detection/sfd/s3fd.pth\')\n    missing = [str(p.relative_to(vendor)) for p in required if not p.is_file() or p.stat().st_size == 0]\n    if missing:\n        raise FileNotFoundError(\'Faltan modelos o hay enlaces rotos: \' + \', \'.join(missing) +\n                                \'. Ejecuta la reparación de modelos; si falta la caché, repite la celda 5.\')\n\n\ndef repair_models(vendor, engine=\'musetalk\'):\n    vendor = Path(vendor).resolve()\n    cache = vendor.parent / \'hf-cache\'\n    missing = []\n    for item in manifest(engine):\n        destination = vendor / \'models\' / item[\'destination\']\n        if destination.is_file() and not destination.is_symlink() and destination.stat().st_size > 0:\n            continue\n        cached = cache / (\'models--\' + item[\'repo\'].replace(\'/\', \'--\')) / \'snapshots\' / item[\'revision\'] / item[\'filename\']\n        if not cached.is_file():\n            missing.append(item[\'destination\'])\n            continue\n        materialize(cached, destination)\n        print(\'Modelo reparado:\', item[\'destination\'], flush=True)\n    if missing:\n        raise FileNotFoundError(\'No están en la caché local: \' + \', \'.join(missing) + \'. Repite la celda 5 con el descargador actualizado.\')\n    check_models(vendor, engine)\n    print(\'Modelos comprobados. Reparación completada sin reinstalar paquetes ni descargar pesos.\', flush=True)\n\n\nif __name__ == \'__main__\':\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\'--vendor\', type=Path, required=True)\n    parser.add_argument(\'--engine\', choices=[\'musetalk\', \'mimicmotion\'], default=\'musetalk\')\n    args = parser.parse_args()\n    repair_models(args.vendor, args.engine)\n', 'models.json': '{\n  "musetalk": [\n    {\n      "repo": "TMElyralab/MuseTalk",\n      "filename": "musetalkV15/musetalk.json",\n      "destination": "musetalkV15/musetalk.json",\n      "revision": "2bcb936e2fddb4d86db4c62fd45b387d0c061571"\n    },\n    {\n      "repo": "TMElyralab/MuseTalk",\n      "filename": "musetalkV15/unet.pth",\n      "destination": "musetalkV15/unet.pth",\n      "revision": "2bcb936e2fddb4d86db4c62fd45b387d0c061571"\n    },\n    {\n      "repo": "stabilityai/sd-vae-ft-mse",\n      "filename": "config.json",\n      "destination": "sd-vae/config.json",\n      "revision": "31f26fdeee1355a5c34592e401dd41e45d25a493"\n    },\n    {\n      "repo": "stabilityai/sd-vae-ft-mse",\n      "filename": "diffusion_pytorch_model.bin",\n      "destination": "sd-vae/diffusion_pytorch_model.bin",\n      "revision": "31f26fdeee1355a5c34592e401dd41e45d25a493"\n    },\n    {\n      "repo": "openai/whisper-tiny",\n      "filename": "config.json",\n      "destination": "whisper/config.json",\n      "revision": "169d4a4341b33bc18d8881c4b69c2e104e1cc0af"\n    },\n    {\n      "repo": "openai/whisper-tiny",\n      "filename": "pytorch_model.bin",\n      "destination": "whisper/pytorch_model.bin",\n      "revision": "169d4a4341b33bc18d8881c4b69c2e104e1cc0af"\n    },\n    {\n      "repo": "openai/whisper-tiny",\n      "filename": "preprocessor_config.json",\n      "destination": "whisper/preprocessor_config.json",\n      "revision": "169d4a4341b33bc18d8881c4b69c2e104e1cc0af"\n    },\n    {\n      "repo": "yzd-v/DWPose",\n      "filename": "dw-ll_ucoco_384.pth",\n      "destination": "dwpose/dw-ll_ucoco_384.pth",\n      "revision": "1a7144101628d69ee7a3768d1ee3a094070dc388"\n    },\n    {\n      "repo": "ManyOtherFunctions/face-parse-bisent",\n      "filename": "79999_iter.pth",\n      "destination": "face-parse-bisent/79999_iter.pth",\n      "revision": "0073b233a5a3c4b1377d4dbf49245017938a72b5"\n    },\n    {\n      "repo": "ManyOtherFunctions/face-parse-bisent",\n      "filename": "resnet18-5c106cde.pth",\n      "destination": "face-parse-bisent/resnet18-5c106cde.pth",\n      "revision": "0073b233a5a3c4b1377d4dbf49245017938a72b5"\n    }\n  ],\n  "mimicmotion": [\n    {\n      "repo": "yzd-v/DWPose",\n      "filename": "yolox_l.onnx",\n      "destination": "DWPose/yolox_l.onnx",\n      "revision": "1a7144101628d69ee7a3768d1ee3a094070dc388"\n    },\n    {\n      "repo": "yzd-v/DWPose",\n      "filename": "dw-ll_ucoco_384.onnx",\n      "destination": "DWPose/dw-ll_ucoco_384.onnx",\n      "revision": "1a7144101628d69ee7a3768d1ee3a094070dc388"\n    },\n    {\n      "repo": "tencent/MimicMotion",\n      "filename": "MimicMotion_1-1.pth",\n      "destination": "MimicMotion_1-1.pth",\n      "revision": "db35a2c5d0a12db9155060f14688e72dedbfb370"\n    }\n  ],\n  "svd": {\n    "repo": "stabilityai/stable-video-diffusion-img2vid-xt-1-1",\n    "revision": "043843887ccd51926e3efed36270444a838e7861"\n  }\n}\n', 'download_models.py': "import argparse\nimport json\nimport os\nfrom pathlib import Path\nimport urllib.request\nfrom huggingface_hub import hf_hub_download, snapshot_download\nfrom model_files import materialize, check_models\n\n\ndef download(vendor, engine):\n    manifest = json.loads(Path(__file__).with_name('models.json').read_text())\n    cache = vendor.parent / 'hf-cache'\n    token = os.environ.get('HF_TOKEN') or None\n    marker = vendor / 'models/.models-manifest.json'\n    previous = json.loads(marker.read_text()) if marker.exists() else None\n    for item in manifest[engine]:\n        destination = vendor / 'models' / item['destination']\n        destination.parent.mkdir(parents=True, exist_ok=True)\n        if previous == manifest[engine] and destination.is_file() and not destination.is_symlink() and destination.stat().st_size > 0:\n            print('Modelo reutilizado:', item['destination'], flush=True)\n            continue\n        print('Modelo:', item['destination'], flush=True)\n        cached = hf_hub_download(item['repo'], item['filename'], revision=item['revision'], cache_dir=str(cache), token=token)\n        materialize(cached, destination)\n    if engine == 'musetalk':\n        s3fd = vendor / 'musetalk/utils/face_detection/detection/sfd/s3fd.pth'\n        if not s3fd.is_file() or s3fd.stat().st_size < 1000000:\n            temporary = s3fd.with_suffix('.part')\n            urllib.request.urlretrieve('https://www.adrianbulat.com/downloads/python-fan/s3fd-619a316812.pth', temporary)\n            if temporary.stat().st_size < 1000000: raise RuntimeError('Descarga incompleta de S3FD.')\n            temporary.replace(s3fd)\n    else:\n        svd = manifest['svd']\n        print('Modelo base SVD: la primera descarga es grande.', flush=True)\n        location = snapshot_download(svd['repo'], revision=svd['revision'], cache_dir=str(cache), token=token,\n            allow_patterns=['model_index.json','scheduler/*','feature_extractor/*','unet/config.json',\n                            'vae/config.json','vae/*fp16.safetensors','image_encoder/config.json','image_encoder/*fp16.safetensors'])\n        (vendor / 'svd-location.txt').write_text(location)\n    check_models(vendor, engine)\n    marker.write_text(json.dumps(manifest[engine], indent=2))\n    print('Modelos descargados con revisiones fijadas.', flush=True)\n\n\nif __name__ == '__main__':\n    parser = argparse.ArgumentParser(); parser.add_argument('--vendor', type=Path, required=True)\n    parser.add_argument('--engine', choices=['musetalk','mimicmotion'], default='musetalk')\n    args = parser.parse_args(); download(args.vendor.resolve(), args.engine)\n", 'render.py': '"""Adaptador de MuseTalk 1.5: prepara entradas y verifica que exista vídeo con audio."""\nimport argparse\nimport json\nimport os\nfrom pathlib import Path\nimport subprocess\nimport sys\nimport tempfile\nfrom model_files import check_models\n\n\ndef probe(path):\n    result = subprocess.check_output([\'ffprobe\', \'-v\', \'error\', \'-show_streams\', \'-show_format\', \'-of\', \'json\', str(path)], text=True)\n    return json.loads(result)\n\n\ndef render(vendor, image, audio, output, source=None, max_seconds=5, batch_size=2):\n    vendor, image, audio, output = [Path(p).resolve() for p in (vendor, image, audio, output)]\n    if not image.is_file() or not audio.is_file():\n        raise FileNotFoundError(\'Falta la imagen o el audio de entrada.\')\n    check_models(vendor)\n    if any(c.isspace() for c in str(vendor)):\n        raise ValueError(\'La carpeta MuseTalk debe tener una ruta sin espacios.\')\n    output.parent.mkdir(parents=True, exist_ok=True)\n    env = os.environ.copy()\n    env.pop(\'PYTHONHOME\', None)\n    env[\'PYTHONNOUSERSITE\'] = \'1\'\n    env[\'PYTHONPATH\'] = os.pathsep.join([str(vendor), str(vendor / \'musetalk/utils\')])\n    env[\'CUDA_VISIBLE_DEVICES\'] = \'0\'\n    env[\'PYTORCH_CUDA_ALLOC_CONF\'] = \'max_split_size_mb:128\'\n    with tempfile.TemporaryDirectory(prefix=\'humana_\', dir=vendor.parent) as tmp:\n        work = Path(tmp)\n        wav = work / \'audio.wav\'\n        audio_args = [\'ffmpeg\', \'-y\', \'-v\', \'error\', \'-i\', str(audio), \'-vn\', \'-ac\', \'1\', \'-ar\', \'16000\']\n        if max_seconds > 0: audio_args += [\'-t\', str(max_seconds)]\n        subprocess.run(audio_args + [str(wav)], check=True)\n        duration = float(probe(wav)[\'format\'][\'duration\'])\n        if duration < 0.3: raise ValueError(\'El audio debe durar al menos 0,3 segundos.\')\n        base = work / \'source.mp4\'\n        if source:\n            source = Path(source).resolve()\n            source_duration = float(probe(source)[\'format\'][\'duration\'])\n            if source_duration + 0.04 < duration:\n                raise ValueError(\'El movimiento corporal es más corto que la voz. Genera un movimiento más largo para evitar repetirlo hacia atrás.\')\n            subprocess.run([\'ffmpeg\', \'-y\', \'-v\', \'error\', \'-i\', str(source), \'-t\', str(duration), \'-an\', \'-r\', \'25\',\n                            \'-vf\', \'scale=trunc(iw/2)*2:trunc(ih/2)*2\', \'-c:v\', \'libx264\', \'-pix_fmt\', \'yuv420p\', str(base)], check=True)\n        else:\n            # Una imagen se convierte en vídeo de un fotograma. Evita el fallo de limpieza\n            # save_dir_full del script oficial al pasar imágenes directamente.\n            subprocess.run([\'ffmpeg\', \'-y\', \'-v\', \'error\', \'-loop\', \'1\', \'-i\', str(image), \'-frames:v\', \'1\', \'-r\', \'25\',\n                            \'-vf\', \'scale=trunc(iw/2)*2:trunc(ih/2)*2\', \'-c:v\', \'libx264\', \'-pix_fmt\', \'yuv420p\', str(base)], check=True)\n        config = work / \'inference.json\'\n        config.write_text(json.dumps({\'humana\': {\'video_path\': str(base), \'audio_path\': str(wav), \'result_name\': \'result.mp4\'}}))\n        result_dir = work / \'result\'\n        command = [sys.executable, \'-m\', \'scripts.inference\', \'--inference_config\', str(config),\n                   \'--result_dir\', str(result_dir), \'--unet_model_path\', \'models/musetalkV15/unet.pth\',\n                   \'--unet_config\', \'models/musetalkV15/musetalk.json\', \'--whisper_dir\', \'models/whisper\',\n                   \'--version\', \'v15\', \'--fps\', \'25\', \'--batch_size\', str(batch_size), \'--use_float16\']\n        subprocess.run(command, cwd=vendor, env=env, check=True)\n        result = result_dir / \'v15/result.mp4\'\n        if not result.is_file() or result.stat().st_size < 1000:\n            raise RuntimeError(\'MuseTalk no creó el vídeo. El script oficial puede devolver código 0 incluso al fallar; revisa el error anterior.\')\n        # Reempaqueta con duración exacta, audio AAC y moov al inicio para navegador/Safari.\n        subprocess.run([\'ffmpeg\', \'-y\', \'-v\', \'error\', \'-i\', str(result), \'-i\', str(wav), \'-map\', \'0:v:0\', \'-map\', \'1:a:0\',\n                        \'-c:v\', \'copy\', \'-c:a\', \'aac\', \'-b:a\', \'128k\', \'-shortest\', \'-movflags\', \'+faststart\', str(output)], check=True)\n        info = probe(output)\n        kinds = {s[\'codec_type\'] for s in info[\'streams\']}\n        if not {\'video\', \'audio\'} <= kinds: raise RuntimeError(\'El resultado no contiene vídeo y audio.\')\n        if abs(float(info[\'format\'][\'duration\']) - duration) > 0.35:\n            raise RuntimeError(\'La duración generada no coincide con el audio.\')\n        video_stream = next(s for s in info[\'streams\'] if s[\'codec_type\'] == \'video\')\n        if int(video_stream.get(\'nb_frames\', 0)) < max(1, int(duration * 25) - 4):\n            raise RuntimeError(\'Faltan fotogramas en el vídeo generado.\')\n        print(json.dumps({\'output\': str(output), \'seconds\': duration, \'animationMode\': \'body-lips\' if source else \'lip-sync-only\'}, ensure_ascii=False))\n    return output\n\n\nif __name__ == \'__main__\':\n    parser = argparse.ArgumentParser()\n    for field in [\'vendor\', \'image\', \'audio\', \'output\']: parser.add_argument(\'--\' + field, type=Path, required=True)\n    parser.add_argument(\'--source\', type=Path)\n    parser.add_argument(\'--max-seconds\', type=float, default=5)\n    parser.add_argument(\'--batch-size\', type=int, default=2)\n    args = parser.parse_args(); render(**vars(args))\n', 'worker.py': '"""Cliente GPU por polling saliente. La GPU no publica ningún servidor o túnel."""\nimport argparse\nimport json\nimport os\nfrom pathlib import Path\nimport threading\nimport time\nimport urllib.parse\nimport requests\nfrom render import render\nfrom model_files import check_models\n\n\ndef connect(base_url, vendor, image, minutes=30, motion_catalog=None):\n    token = os.environ.get(\'HUMANA_WORKER_TOKEN\', \'\')\n    if not token: raise RuntimeError(\'Configura HUMANA_WORKER_TOKEN en Kaggle Secrets.\')\n    parsed = urllib.parse.urlparse(base_url)\n    if parsed.scheme != \'https\' or not parsed.netloc or parsed.username or parsed.password or parsed.query:\n        raise ValueError(\'La URL debe ser la raíz HTTPS de tu web Cloudflare.\')\n    base_url = base_url.rstrip(\'/\')\n    vendor, image = Path(vendor).resolve(), Path(image).resolve()\n    check_models(vendor)\n    if not image.is_file(): raise FileNotFoundError(\'Falta la imagen de identidad de la GPU.\')\n    import torch\n    if not torch.cuda.is_available(): raise RuntimeError(\'Activa la GPU antes de conectar.\')\n    device = torch.cuda.get_device_name(0)\n    deadline = time.monotonic() + minutes * 60\n    headers = {\'Authorization\': \'Bearer \' + token}\n    motions = json.loads(Path(motion_catalog).read_text()) if motion_catalog else {}\n    work = vendor.parent / \'cloudflare-jobs\'; work.mkdir(exist_ok=True)\n\n    def api(path, method=\'POST\', **kwargs):\n        r = requests.request(method, base_url + \'/api/\' + path, headers={**headers, **kwargs.pop(\'headers\', {})}, timeout=60, **kwargs)\n        if not r.ok:\n            try: message = r.json().get(\'error\', \'Error API\')\n            except ValueError: message = f\'HTTP {r.status_code}\'\n            raise RuntimeError(message)\n        return r\n\n    print(f\'Sesión GPU: {device}. Conectada durante unos {minutes} minutos.\', flush=True)\n    while time.monotonic() < deadline:\n        try: job = api(\'worker/claim\', json={\'device\': device}).json()[\'job\']\n        except requests.RequestException:\n            print(\'Conexión interrumpida; reintentando...\', flush=True); time.sleep(10); continue\n        if not job: time.sleep(5); continue\n        job_id, lease = job[\'id\'], job[\'leaseId\']\n        stop = threading.Event(); lost = threading.Event()\n\n        def renew():\n            while not stop.wait(25):\n                try: api(\'worker/heartbeat\', json={\'device\': device, \'jobId\': job_id, \'leaseId\': lease})\n                except Exception as error:\n                    print(\'No se pudo renovar la sesión:\', type(error).__name__, flush=True)\n                    lost.set()\n        heartbeat = threading.Thread(target=renew, daemon=True); heartbeat.start()\n        try:\n            folder = work / job_id; folder.mkdir(exist_ok=True)\n            audio = folder / \'speech.mp3\'\n            audio.write_bytes(api(f\'worker/jobs/{job_id}/audio\', \'GET\', headers={\'X-Lease-Id\': lease}).content)\n            gesture = job[\'plan\'][\'gesture\']\n            source = Path(motions[gesture]).resolve() if gesture in motions else None\n            result = render(vendor, image, audio, folder / \'response.mp4\', source=source, max_seconds=0)\n            if lost.is_set(): raise RuntimeError(\'La GPU perdió la conexión durante el trabajo; no se sube un resultado con sesión incierta.\')\n            if result.stat().st_size > 50 * 1024 * 1024: raise RuntimeError(\'El resultado supera 50 MB. Reduce la resolución o la longitud de las respuestas.\')\n            with result.open(\'rb\') as stream:\n                api(f\'worker/jobs/{job_id}/result\', \'PUT\', data=stream,\n                    headers={\'X-Lease-Id\': lease, \'Content-Type\': \'video/mp4\', \'X-Animation-Mode\': \'body-lips\' if source else \'lip-sync-only\'})\n            print(\'Vídeo entregado:\', job_id, flush=True)\n        except Exception as error:\n            print(\'Trabajo fallido:\', type(error).__name__, str(error), flush=True)\n            try: api(f\'worker/jobs/{job_id}/fail\', headers={\'X-Lease-Id\': lease}, json={})\n            except Exception: pass\n        finally:\n            stop.set(); heartbeat.join(timeout=2)\n    print(\'Sesión terminada. La cola y los vídeos siguen guardados en Cloudflare.\', flush=True)\n\n\nif __name__ == \'__main__\':\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\'--base-url\', required=True)\n    parser.add_argument(\'--vendor\', type=Path, required=True)\n    parser.add_argument(\'--image\', type=Path, required=True)\n    parser.add_argument(\'--minutes\', type=int, default=30)\n    parser.add_argument(\'--motion-catalog\', type=Path)\n    connect(**vars(parser.parse_args()))\n'}
for name, content in PAYLOAD.items():
    (GPU / name).write_text(content, encoding='utf-8')
ENV = os.environ.copy()
for key in ['PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'CONDA_PREFIX']:
    ENV.pop(key, None)
ENV['PYTHONNOUSERSITE'] = '1'
ENV['CUDA_VISIBLE_DEVICES'] = '0'
ENV['PYTORCH_CUDA_ALLOC_CONF'] = 'max_split_size_mb:128'
torch_lib = PYTHON.parent.parent / 'lib/python3.10/site-packages/torch/lib'
ENV['LD_LIBRARY_PATH'] = str(torch_lib) + ':' + ENV.get('LD_LIBRARY_PATH', '')
def run(args, **kwargs):
    subprocess.run([str(a) for a in args], check=True, env=ENV, **kwargs)
run([sys.executable, GPU / 'model_files.py', '--vendor', VENDOR])
print('Cliente actualizado. Vuelve a ejecutar la celda 7 y revisa el vídeo; después puedes conectar Cloudflare en la celda 10.')
