Spaces:
Running
Running
File size: 17,473 Bytes
05d246f 0c4802c 328962c 0c4802c 77840ef 38d8665 b62c1de 38d8665 77840ef 3e76a85 4705133 781ace6 a09b5cb 0c4802c 0c63a52 4705133 860e872 a53b476 860e872 0c4802c a53b476 0c4802c 38d8665 c97614a b62c1de a98fdd9 b62c1de c97614a 0c4802c 5d9ba74 72214cd 5d9ba74 e65b6b1 3f5b0be e65b6b1 5d9ba74 72214cd 0c4802c 50a2078 0c4802c 781ace6 c772a24 d4cb333 c772a24 b4fcea4 50ad5e4 b4fcea4 50a2078 b4fcea4 c772a24 b4fcea4 df20ad5 d4cb333 781ace6 c772a24 50a2078 c772a24 0c4802c 5f109e4 0c4802c 38d8665 0c4802c d4cb333 0c4802c ceea752 0c4802c a53b476 0c4802c da68fbf 3c8f3ce da68fbf 0c4802c f9f6bba 0c4802c 9cef76a ceea752 9cef76a 715d17a 0c4802c 715d17a 2b79235 715d17a 6edaca8 715d17a 6edaca8 715d17a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | 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
@app.get("/descargar")
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"}
@app.get("/", response_class=HTMLResponse)
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)
|