Spaces:
Running
Running
| import subprocess | |
| try: | |
| subprocess.run(["sed", "-i", 's/rights="none"/rights="read|write"/g', "/etc/ImageMagick-6/policy.xml"], capture_output=True) | |
| subprocess.run(["sed", "-i", 's/rights="none"/rights="read|write"/g', "/etc/ImageMagick-7/policy.xml"], capture_output=True) | |
| except: pass | |
| import os, asyncio, time, random, requests, threading | |
| import PIL.Image | |
| if not hasattr(PIL.Image, "ANTIALIAS"): PIL.Image.ANTIALIAS = PIL.Image.LANCZOS | |
| import gradio as gr | |
| from groq import Groq | |
| import edge_tts | |
| from moviepy.editor import VideoFileClip, AudioFileClip, TextClip, CompositeVideoClip | |
| NICHO = "NICHO_PLACEHOLDER" | |
| import json as _json | |
| try: | |
| CONFIG = _json.loads("""CONFIG_PLACEHOLDER""") | |
| except: | |
| CONFIG = {} | |
| # Mapeo de voces edge-tts | |
| VOCES = { | |
| "es": { | |
| "masculina": ["es-ES-AlvaroNeural", "es-MX-JorgeNeural", "es-AR-TomasNeural", "es-CO-GonzaloNeural"], | |
| "femenina": ["es-ES-ElviraNeural", "es-MX-DaliaNeural", "es-AR-ElenaNeural", "es-CO-SalomeNeural"] | |
| }, | |
| "en": { | |
| "masculina": ["en-US-GuyNeural", "en-US-DavisNeural", "en-GB-RyanNeural", "en-AU-WilliamNeural"], | |
| "femenina": ["en-US-JennyNeural", "en-US-AriaNeural", "en-GB-SoniaNeural", "en-AU-NatashaNeural"] | |
| }, | |
| "pt": { | |
| "masculina": ["pt-BR-AntonioNeural", "pt-BR-FabioNeural"], | |
| "femenina": ["pt-BR-FranciscaNeural", "pt-BR-BrendaNeural"] | |
| }, | |
| "fr": { | |
| "masculina": ["fr-FR-HenriNeural", "fr-FR-JeromeNeural"], | |
| "femenina": ["fr-FR-DeniseNeural", "fr-FR-EloiseNeural"] | |
| } | |
| } | |
| CLIENT_EMAIL = "EMAIL_PLACEHOLDER" | |
| HF_REPO = "REPO_PLACEHOLDER" | |
| # Descargar token.json del repo si existe | |
| try: | |
| import urllib.request | |
| urllib.request.urlretrieve(f"https://huggingface.co/spaces/{HF_REPO}/resolve/main/token.json", "token.json") | |
| print("Token descargado", flush=True) | |
| except Exception as e: | |
| print(f"Sin token aún: {e}", flush=True) | |
| GROQ_KEYS = ["GROQ_PLACEHOLDER", "GROQ_PLACEHOLDER_2", "GROQ_PLACEHOLDER_3"] | |
| GROQ_KEYS = [k for k in GROQ_KEYS if k and not k.startswith("GROQ_PLACEHOLDER")] | |
| if not GROQ_KEYS: | |
| GROQ_KEYS = ["GROQ_PLACEHOLDER"] | |
| GROQ_KEY = GROQ_KEYS[0] | |
| TIPO_VIDEO = "TIPO_PLACEHOLDER" | |
| PEXELS_KEY = "PEXELS_PLACEHOLDER" | |
| LOGS = [] | |
| STATUS = "Iniciando..." | |
| def log(msg): | |
| t = time.strftime("%H:%M:%S") | |
| LOGS.append(f"[{t}] {msg}") | |
| if len(LOGS) > 40: LOGS.pop(0) | |
| print(msg, flush=True) | |
| def generar_guion(): | |
| _tono_actual = CONFIG.get("tono", "educativo") | |
| last_err = None | |
| for _key in GROQ_KEYS: | |
| try: | |
| client = Groq(api_key=_key) | |
| return _generar_guion_con(client, _tono_actual) | |
| except Exception as _e: | |
| last_err = _e | |
| if "rate_limit" in str(_e).lower() or "429" in str(_e): | |
| continue | |
| else: | |
| raise | |
| raise last_err if last_err else Exception("Sin keys Groq") | |
| def _generar_guion_con(client, _tono_actual): | |
| _canal_url = CONFIG.get("canal_promocionar", "").strip() | |
| _contexto_canal = f" El video debe ser un teaser que invite a ver el canal {_canal_url}." if _canal_url else "" | |
| res = client.chat.completions.create( | |
| messages=[{"role":"user","content":f"Escribe SOLO el texto a narrar en un video de 60 segundos sobre {NICHO}. Tono: {_tono_actual}.{_contexto_canal} NO incluyas indicaciones como narrador dice, voz en off, intro, outro, ni nada que no sea el texto a leer. Directo al grano, sin simbolos, sin hashtags, sin emojis."}], | |
| model="llama-3.3-70b-versatile", temperature=0.82 | |
| ) | |
| guion_base = res.choices[0].message.content.strip() | |
| frase_i = CONFIG.get("frase_inicio", "").strip() | |
| frase_f = CONFIG.get("frase_final", "").strip() | |
| partes = [] | |
| if frase_i: partes.append(frase_i) | |
| partes.append(guion_base) | |
| if frase_f: partes.append(frase_f) | |
| return ". ".join(partes) | |
| async def pipeline(): | |
| global STATUS | |
| while True: | |
| try: | |
| STATUS = "Generando guion..." | |
| log(f"Nuevo video: {NICHO}") | |
| guion = generar_guion() | |
| log("Guion listo") | |
| STATUS = "Generando voz..." | |
| voz_ia_on = CONFIG.get("voz_ia", True) | |
| if voz_ia_on: | |
| idioma_v = CONFIG.get("idioma", "es") | |
| genero_v = CONFIG.get("voz_genero", "masculina") | |
| import random as _rnd | |
| voces_disponibles = VOCES.get(idioma_v, VOCES["es"]).get(genero_v, ["es-ES-AlvaroNeural"]) | |
| if isinstance(voces_disponibles, list): | |
| # Filtrar por pais si esta configurado | |
| pais_voz = CONFIG.get("pais_voz", "").lower() | |
| if pais_voz: | |
| cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","britanico":"GB","australia":"AU","brasil":"BR","francia":"FR"} | |
| cod = cods.get(pais_voz, "") | |
| if cod: | |
| filtradas = [v for v in voces_disponibles if f"-{cod}-" in v] | |
| if filtradas: | |
| voces_disponibles = filtradas | |
| if isinstance(voces_disponibles, str): | |
| voz_seleccionada = voces_disponibles | |
| else: | |
| voz_seleccionada = _rnd.choice(voces_disponibles) | |
| log(f"Voz elegida: {voz_seleccionada}") | |
| vel = CONFIG.get("velocidad_voz", 1.0) | |
| rate_str = f"+{int((vel-1)*100)}%" if vel >= 1 else f"-{int((1-vel)*100)}%" | |
| await edge_tts.Communicate(guion, voz_seleccionada, rate=rate_str).save("voz.mp3") | |
| else: | |
| # Sin voz IA: crear audio en silencio | |
| import subprocess | |
| subprocess.run(["ffmpeg","-y","-f","lavfi","-i","anullsrc=r=44100:cl=mono","-t","60","voz.mp3"], capture_output=True) | |
| log("Voz lista") | |
| STATUS = "Preparando fondos..." | |
| fondos = [] | |
| # Revisar si hay media propia del cliente | |
| archivos_propios = [] | |
| import glob as _g | |
| log(f"DEBUG cwd: {os.getcwd()}") | |
| log(f"DEBUG ls: {os.listdir('.')}") | |
| for ext in ["jpg","jpeg","png","webp","mp4","mov"]: | |
| archivos_propios.extend(sorted(_g.glob(f"media_*.{ext}"))) | |
| log(f"DEBUG archivos encontrados: {archivos_propios}") | |
| if archivos_propios: | |
| random.shuffle(archivos_propios) | |
| fondos = archivos_propios[:6] | |
| log(f"Usando {len(fondos)} archivos del cliente") | |
| else: | |
| STATUS = "Descargando fondo..." | |
| h = {"Authorization": PEXELS_KEY} | |
| r = requests.get(f"https://api.pexels.com/videos/search?query={NICHO}&per_page=15", headers=h) | |
| vids = r.json().get("videos", []) | |
| if vids: | |
| random.shuffle(vids) | |
| for i, v in enumerate(vids[:6]): | |
| try: | |
| u = v["video_files"][0]["link"] | |
| with open(f"fondo{i}.mp4","wb") as f: f.write(requests.get(u, timeout=30).content) | |
| fondos.append(f"fondo{i}.mp4") | |
| except: pass | |
| log(f"Fondos listos: {len(fondos)}") | |
| STATUS = "Renderizando video..." | |
| from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, concatenate_videoclips | |
| voz = AC("voz.mp3") | |
| if TIPO_VIDEO == "short": | |
| clips_lista = [] | |
| dur_total_real = min(voz.duration, 58) | |
| # Cada plano max 4s, calcular cuantos planos necesitamos | |
| num_planos = max(int(dur_total_real / 4) + 1, len(fondos)) | |
| # Repetir fondos si hace falta | |
| fondos_loop = (fondos * ((num_planos // len(fondos)) + 1))[:num_planos] | |
| dur_clip = dur_total_real / num_planos | |
| log(f"Planos: {num_planos}, dur_clip: {dur_clip:.1f}s") | |
| for fp in fondos_loop: | |
| try: | |
| es_img = fp.lower().endswith((".jpg",".jpeg",".png",".webp")) | |
| if es_img: | |
| from PIL import Image as PILImage | |
| img = PILImage.open(fp).convert("RGB") | |
| w, h = img.size | |
| bg = img.resize((1080, 1920), PILImage.LANCZOS) | |
| from PIL import ImageFilter as _PIF | |
| bg = bg.filter(_PIF.GaussianBlur(40)) | |
| ratio = min(1080/w, 1920/h) | |
| new_w = int(w * ratio) | |
| new_h = int(h * ratio) | |
| fg = img.resize((new_w, new_h), PILImage.LANCZOS) | |
| offset_x = (1080 - new_w) // 2 | |
| offset_y = (1920 - new_h) // 2 | |
| bg.paste(fg, (offset_x, offset_y)) | |
| img = bg | |
| img.save(fp + "_r.jpg", "JPEG", quality=90) | |
| cc = IC(fp + "_r.jpg").set_duration(dur_clip) | |
| else: | |
| cc = VC(fp).without_audio().loop(duration=dur_clip) | |
| if cc.w/cc.h > 1080/1920: | |
| cc = cc.resize(height=1920) | |
| else: | |
| cc = cc.resize(width=1080) | |
| cc = cc.crop(x_center=cc.w/2, y_center=cc.h/2, width=1080, height=1920) | |
| clips_lista.append(cc) | |
| except Exception as e_c: | |
| log(f"Error clip {fp}: {e_c}") | |
| log(f"Clips OK: {len(clips_lista)}/{len(fondos)}") | |
| clip = concatenate_videoclips(clips_lista, method="compose") | |
| dur_final = min(voz.duration, 58) | |
| clip = clip.subclip(0, dur_final) | |
| voz = voz.subclip(0, dur_final) | |
| else: | |
| clips_lista = [] | |
| for fp in fondos: | |
| try: | |
| cc = IC(fp, duration=5).resize(width=1280) if fp.lower().endswith((".jpg",".jpeg",".png",".webp")) else VC(fp).without_audio().resize(width=1280) | |
| cc = cc.loop(duration=voz.duration/max(len(fondos),1)) | |
| clips_lista.append(cc) | |
| except: pass | |
| clip = concatenate_videoclips(clips_lista, method="compose") | |
| palabras = guion.split() | |
| frases = [" ".join(palabras[i:i+6]) for i in range(0,len(palabras),6)] | |
| t_f = voz.duration / max(len(frases),1) | |
| if not CONFIG.get("subtitulos", True): | |
| txts = [] | |
| else: | |
| txts = [TextClip(t.upper(), fontsize={"tiktok":72,"clasico":50,"minimal":40}.get(CONFIG.get("estilo_subtitulos","tiktok"),72), color=CONFIG.get("color_subtitulos","#D4AF37"), font="DejaVu-Sans-Bold", | |
| stroke_color="black", stroke_width=2, method="caption", | |
| size=(clip.w*0.8,None)).set_start(i*t_f).set_duration(t_f).set_pos(("center", {"arriba":0.15,"centro":0.5,"abajo":0.75}.get(CONFIG.get("posicion_subtitulos","abajo"),0.75)), relative=True) | |
| for i,t in enumerate(frases)] | |
| final = CompositeVideoClip([clip]+txts).set_audio(voz) | |
| final.write_videofile("out.mp4", fps=30, codec="libx264", bitrate="1500k", preset="medium", logger=None, threads=2, audio_codec="aac", audio_bitrate="128k") | |
| log("Video renderizado") | |
| STATUS = "Esperando conexión YouTube..." | |
| while not os.path.exists("token.json"): | |
| await asyncio.sleep(30) | |
| STATUS = "Subiendo a YouTube..." | |
| try: | |
| import json, google.oauth2.credentials, googleapiclient.discovery, googleapiclient.http | |
| with open("token.json") as f: | |
| tok = json.load(f) | |
| creds = google.oauth2.credentials.Credentials( | |
| token=tok["access_token"], | |
| refresh_token=tok.get("refresh_token"), | |
| token_uri="https://oauth2.googleapis.com/token", | |
| client_id=tok.get("client_id",""), | |
| client_secret=tok.get("client_secret","") | |
| ) | |
| yt = googleapiclient.discovery.build("youtube","v3",credentials=creds) | |
| titulo = guion[:80].split(".")[0] | |
| req_yt = yt.videos().insert( | |
| part="snippet,status", | |
| body={ | |
| "snippet":{"title":titulo,"description":guion + ("\n\n" + CONFIG.get("link_descripcion","") if CONFIG.get("link_descripcion") else ""),"categoryId":"22"}, | |
| "status":{"privacyStatus":"public"} | |
| }, | |
| media_body=googleapiclient.http.MediaFileUpload("out.mp4",mimetype="video/mp4",resumable=True) | |
| ) | |
| resp = None | |
| while resp is None: | |
| _, resp = req_yt.next_chunk() | |
| log(f"Subido: {titulo[:40]}") | |
| # Reportar uso al IAweb | |
| try: | |
| proyecto_idx = CONFIG.get("proyecto_idx", -1) | |
| if proyecto_idx >= 0: | |
| requests.post("https://cristobal299-iaweb.hf.space/reportar-uso", | |
| json={"proyecto_idx": proyecto_idx, "email": CLIENT_EMAIL, "titulo": titulo}, timeout=10) | |
| except Exception as eu: | |
| log(f"No se pudo reportar uso: {eu}") | |
| except FileNotFoundError: | |
| log("Sin token.json, saltando subida") | |
| except Exception as ey: | |
| log(f"Error YouTube: {ey}") | |
| STATUS = "Esperando siguiente ciclo..." | |
| await asyncio.sleep(14400) | |
| except Exception as e: | |
| log(f"Error: {e}") | |
| STATUS = f"Error: {str(e)[:40]}" | |
| await asyncio.sleep(600) | |
| def start(): | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| time.sleep(30); loop.run_until_complete(pipeline()) | |
| threading.Thread(target=start, daemon=True).start() | |
| CONTADOR_VIDEOS = 0 | |
| ULTIMO_VIDEO = "Ninguno aún" | |
| def youtube_conectado(): | |
| return os.path.exists("token.json") | |
| def get_yt_status(): | |
| if youtube_conectado(): | |
| return "✅ YouTube conectado — publicando automáticamente" | |
| return "❌ YouTube NO conectado — conecta tu canal para publicar" | |
| def get_stats(): | |
| return f"📊 Videos publicados: {CONTADOR_VIDEOS} | 🎬 Último: {ULTIMO_VIDEO} | ⚡ Estado: {STATUS}" | |
| from fastapi import FastAPI | |
| from fastapi.responses import HTMLResponse | |
| import uvicorn | |
| app = FastAPI() | |
| from fastapi.responses import FileResponse as _FR | |
| async def descargar_video(): | |
| if os.path.exists("out.mp4"): | |
| return _FR("out.mp4", media_type="video/mp4", filename="tubebot_video.mp4") | |
| return {"error": "Aun no hay video generado"} | |
| async def panel(): | |
| yt_status = "✅ YouTube conectado" if os.path.exists("token.json") else "❌ YouTube NO conectado" | |
| logs_html = "<br>".join(LOGS[-20:]) if LOGS else "Iniciando..." | |
| return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>TubeBot</title> | |
| <meta http-equiv="refresh" content="5"> | |
| <style> | |
| body{{background:#0a0a0a;color:#fff;font-family:system-ui;margin:0;padding:20px;max-width:900px;margin:0 auto}} | |
| h1{{color:#ff2d2d;font-size:3rem;text-align:center;font-weight:900;letter-spacing:2px}} | |
| .box{{background:#111;border:1px solid #333;border-radius:12px;padding:20px;margin:20px 0}} | |
| .stat{{font-size:1.1rem;color:#D4AF37}} | |
| .logs{{background:#000;color:#D4AF37;padding:15px;border-radius:8px;font-family:monospace;height:400px;overflow-y:auto;font-size:0.9rem;line-height:1.6}} | |
| .btn{{display:block;background:#ff0000;color:#fff;padding:18px;border-radius:10px;text-decoration:none;font-weight:800;text-align:center;font-size:1.2rem;margin:20px 0}} | |
| .btn:hover{{background:#cc0000}} | |
| hr{{border:none;border-top:1px solid #333;margin:25px 0}} | |
| </style></head> | |
| <body> | |
| <h1>🤖 TUBEBOT — {NICHO.upper()}</h1> | |
| <p style="text-align:center;color:#888">Tu canal publica solo cada 4 horas</p> | |
| <div class="box"> | |
| <div class="stat">📊 <b>Videos publicados:</b> {CONTADOR_VIDEOS}</div> | |
| <div class="stat">🎬 <b>Último video:</b> {ULTIMO_VIDEO}</div> | |
| <div class="stat">⚡ <b>Estado:</b> {STATUS}</div> | |
| <div class="stat">📺 <b>YouTube:</b> {yt_status}</div> | |
| <div class="stat">⏰ <b>Próximo video en:</b> <span id="countdown">--:--:--</span></div> | |
| </div> | |
| <script> | |
| let nextVideo = new Date().getTime() + 4*60*60*1000; | |
| setInterval(()=>{{ | |
| let now = new Date().getTime(); | |
| let diff = nextVideo - now; | |
| if(diff<0){{nextVideo = now + 4*60*60*1000; diff = 4*60*60*1000;}} | |
| let h = Math.floor(diff/(1000*60*60)); | |
| let m = Math.floor((diff%(1000*60*60))/(1000*60)); | |
| let s = Math.floor((diff%(1000*60))/1000); | |
| document.getElementById("countdown").innerText = h+"h "+m+"m "+s+"s"; | |
| }}, 1000); | |
| </script> | |
| <hr> | |
| <h3 style="color:#ff2d2d">📜 Logs en tiempo real</h3> | |
| <div class="logs">{logs_html}</div> | |
| <hr> | |
| <p style="text-align:center;color:#888">💡 ¿Problemas? WhatsApp: +34656691085</p> | |
| </body></html>""" | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |