import os, time, json, glob, random, asyncio, base64 from fastapi import FastAPI, Request, UploadFile, File from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from groq import Groq import edge_tts, requests # Servicio Veo independiente (con cascada a Leonardo.ai) try: from veo_service import generar_clips_escenas as generar_runway except Exception: def generar_runway(escenas): return None try: from leonardo_service import generar_clips_escenas as generar_leonardo except Exception: def generar_leonardo(escenas): return None try: from ltx_service import generar_clips_escenas as generar_ltx except Exception: def generar_ltx(escenas): return None def generar_clips_escenas(escenas, nicho="espiritualidad", motor="auto"): import copy if motor == "ltx": res = generar_ltx(copy.deepcopy(escenas), nicho=nicho) if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res print("[Cerebro] LTX elegido pero fallo.", flush=True); return escenas if motor == "veo": res = generar_runway(copy.deepcopy(escenas)) if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res print("[Cerebro] Veo elegido pero fallo.", flush=True); return escenas if motor == "leonardo": res_leo = generar_leonardo(copy.deepcopy(escenas)) return res_leo if res_leo else escenas res = generar_ltx(copy.deepcopy(escenas), nicho=nicho) if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res print("[Cerebro] LTX no disponible. Saltando a Runway...", flush=True) res = generar_runway(copy.deepcopy(escenas)) if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res print("[Cerebro] Saltando a Leonardo.ai...", flush=True) res_leo = generar_leonardo(copy.deepcopy(escenas)) return res_leo if res_leo else escenas app = FastAPI() GROQ_KEYS = [k for k in [os.environ.get("GROQ_KEY",""), os.environ.get("GROQ_KEY_2",""), os.environ.get("GROQ_KEY_3","")] if k] CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026") def verificar_clave(clave): return clave == CLAVE_ACCESO PEXELS_KEY = os.environ.get("PEXELS_KEY","") # Estado de la sesion (guarda guion editado y material entre pasos) ESTADO = { "guion": "", "archivos": [], "voz_lista": False, "video_listo": False, } ESTILOS = { "narrativo": "narrativo y emocional, como contando una historia", "educativo": "educativo y claro, explicando con autoridad", "motivador": "motivador e inspirador, que mueva a la accion", "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta", "misterio": "intrigante y de misterio, con ganchos de curiosidad", } 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-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]}, } def log(m): print(m, flush=True) # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua. # Si falla, devuelve el clip original (nunca rompe el montaje). def quitar_marca_meta(path, _cache={}): exts_v = (".mp4",".mov",".webm",".avi",".mkv") exts_i = (".jpg",".jpeg",".png",".webp") low = str(path).lower() if not low.endswith(exts_v + exts_i): return path if path in _cache: return _cache[path] try: import subprocess dims = subprocess.run( ["ffprobe","-v","error","-select_streams","v:0", "-show_entries","stream=width,height","-of","csv=p=0:s=x", path], capture_output=True, text=True, timeout=20).stdout.strip() W, H = [int(v) for v in dims.split("x")[:2]] es_video = low.endswith(exts_v) salida = path + ("_clean.mp4" if es_video else "_clean.png") tiene_logo = os.path.exists("logo.png") if tiene_logo: # === Sello real "Teshua" en PNG transparente, abajo-derecha, discreto === target_w = int(W * 0.30) margin = int(W * 0.04) vf = ( f"[1:v]scale={target_w}:-1,format=rgba,colorchannelmixer=aa=0.8[wm];" f"[0:v][wm]overlay=W-w-{margin}:H-h-{margin}[vout]" ) cmd = ["ffmpeg","-y","-v","error","-i",path] if es_video: # Mapeo explícito: video del filtro + audio opcional ("?" evita colgarse si el clip no trae audio, como los de LTX que salen con -an) cmd += ["-loop","1","-i","logo.png","-filter_complex",vf, "-map","[vout]","-map","0:a?","-shortest","-c:a","aac",salida] else: cmd += ["-loop","1","-i","logo.png","-filter_complex",vf,"-map","[vout]","-frames:v","1",salida] else: # === Fallback: si no subiste logo.png, se mantiene la caja+texto de antes === fs = max(22, int(H * 0.030)) pad = int(fs * 0.5) bx = W - int(W * 0.23) by = H - int(H * 0.065) bw = int(W * 0.22) bh = int(H * 0.060) tx = bx + int(bw * 0.08) ty = by + int(bh * 0.20) vf = ( f"drawbox=x={bx}:y={by}:w={bw}:h={bh}:color=black@1:t=fill," f"drawbox=x={bx}:y={by}:w={bw}:h={bh}:color=0xFFD700@1:t=2," f"drawtext=text='Teshua':fontsize={fs}:fontcolor=0xFFD700:x={tx}:y={ty}:font=serif:borderw=2:bordercolor=black" ) cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf] cmd += (["-c:a","copy",salida] if es_video else [salida]) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: log(f"marca ffmpeg error: {result.stderr[:300]}") if os.path.exists(salida) and os.path.getsize(salida) > 0: log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if tiene_logo else 'no (fallback caja)'})") _cache[path] = salida; return salida except subprocess.TimeoutExpired: log(f"marca error: ffmpeg colgado (>60s) en {os.path.basename(path)}, se usa clip original") except Exception as e: log(f"marca error: {e}") _cache[path] = path return path # ========== GUION ========== def generar_guion(texto_base, estilo, duracion=60): estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"]) palabras = int(duracion * 2.4) last_err = None MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"] prompt = f"Basandote en este contenido:\n\n{texto_base}\n\nEscribe SOLO el texto a narrar en un video de {duracion} segundos (aproximadamente {palabras} palabras, IMPORTANTE: debe ser largo si la duracion es alta). Estilo: {estilo_desc}. Usa psicologia de retencion: gancho fuerte en los primeros 3 segundos, desarrollo con curiosidad, cierre con llamada a la accion. NO incluyas indicaciones tipo narrador, voz en off, intro, outro. Solo el texto a leer, sin simbolos, sin hashtags, sin emojis." # Probar cada modelo con cada key hasta que uno funcione for modelo in MODELOS: for k in GROQ_KEYS: try: client = Groq(api_key=k) res = client.chat.completions.create( messages=[{"role":"user","content":prompt}], model=modelo, temperature=0.85 ) return res.choices[0].message.content.strip() except Exception as e: last_err = e if "rate_limit" in str(e).lower() or "429" in str(e): continue if "model" in str(e).lower() or "decommission" in str(e).lower(): break continue raise last_err if last_err else Exception("Sin keys Groq") def prompt_visual_auto(texto_escena): if not texto_escena or not texto_escena.strip(): texto_escena = "cinematic atmospheric scene" instruccion = ( "Eres un director de fotografia escribiendo el prompt para un modelo de video por IA (LTX). " "Crea UN solo prompt en INGLES, fotorrealista y cinematografico, que ilustre la frase de abajo. " "Escribelo como UNA descripcion fluida y en orden cronologico: " "primero el plano y el encuadre, luego el sujeto y el entorno con detalle concreto, " "luego la luz, el color y la atmosfera, y por ultimo UN movimiento de camara LENTO y SUAVE " "(un push-in lento, una deriva suave, un tilt ascendente) mas algun movimiento de la escena " "tambien lento y simple (la luz que cambia, particulas que flotan, niebla que se desplaza). " "REGLA CLAVE para que el video no se deforme: el movimiento debe ser lento, continuo y simple; " "NADA de movimiento rapido, accion compleja, ni personas con gestos detallados de manos o cara. " "REGLA DE LUZ: la escena SIEMPRE claramente iluminada, con una fuente de luz visible " "(luz de luna, horizonte que brilla, rayos de sol, estrellas, una vela); " "NUNCA negro total ni infraexpuesto, el cuadro debe verse con claridad. " "Entre 60 y 110 palabras, concreto, sin contradicciones, sin listas, sin numeros, sin comillas. " "Solo el prompt en ingles. Frase: " + texto_escena) for modelo in ["llama-3.1-8b-instant", "llama-3.3-70b-versatile"]: for k in GROQ_KEYS: try: client = Groq(api_key=k) res = client.chat.completions.create( messages=[{"role":"user","content":instruccion}], model=modelo, temperature=0.8) return res.choices[0].message.content.strip().strip('"') except Exception as e: if "rate_limit" in str(e).lower() or "429" in str(e): continue if "model" in str(e).lower() or "decommission" in str(e).lower(): break continue return texto_escena # ========== VOZ ========== # Voz "Guía Solemne (Hombre Profundo)" — misma configuración que en el chat de Fénix VOZ_GUIA_SOLEMNE = {"voice": "es-ES-AlvaroNeural", "rate": "-6%", "pitch": "-22Hz"} async def generar_voz(guion, idioma="es", genero="masculina", pais=""): # Voz especial con rate/pitch propios (no entra en la selección aleatoria) if genero == "guia_solemne": await edge_tts.Communicate( guion, VOZ_GUIA_SOLEMNE["voice"], rate=VOZ_GUIA_SOLEMNE["rate"], pitch=VOZ_GUIA_SOLEMNE["pitch"], ).save("voz.mp3") return VOZ_GUIA_SOLEMNE["voice"] voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"]) if pais: cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"} cod = cods.get(pais.lower(),"") if cod: filt = [v for v in voces if f"-{cod}-" in v] if filt: voces = filt voz_sel = random.choice(voces) await edge_tts.Communicate(guion, voz_sel).save("voz.mp3") return voz_sel # ========== VIDEO ========== def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None): subs = subs or {} material_escenas = material_escenas or [] escenas_data = escenas_data or [] from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips from PIL import Image as PILImage, ImageFilter as PIF, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True voz = AC("voz.mp3") log(f"[Video] iniciando montaje | escenas_data={len(escenas_data)} material_escenas={len(material_escenas)} archivos={len(archivos)}") # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales if material_escenas: fondos = [m + "_clean.mp4" if os.path.exists(m + "_clean.mp4") else m for m in material_escenas if os.path.exists(m) or os.path.exists(m + "_clean.mp4")] else: fondos = [] # Respaldo: si no hay fondos de escenas validos, usar el material general if not fondos: fondos = archivos[:8] if archivos else [] # Si SIGUE sin haber ningun fondo (ni escenas ni archivos subidos), no hay con que montar if not fondos: raise Exception("[Fenix Error] No hay material de video (LTX/Veo/Leonardo fallaron y no subiste archivos propios).") # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar === fondos = [quitar_marca_meta(f) for f in fondos] if material_escenas: material_escenas = [quitar_marca_meta(m) for m in material_escenas] if escenas_data: escenas_data = [(quitar_marca_meta(m) if m else m, s) for m, s in escenas_data] # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz if escenas_data: suma_seg = sum(s for m,s in escenas_data) dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200) else: dur_total = min(voz.duration, 1200) # Si el material son SOLO videos, usar el video entero de fondo (no trocear) solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos) if solo_videos: from moviepy.editor import concatenate_videoclips as _ccv vclips = [] for fp in fondos: try: vc = VC(fp).without_audio() vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080) vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920) vclips.append(vc) except Exception as e: log(f"Error video {fp}: {e}") if vclips: base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0] # Repetir el video en bucle hasta cubrir toda la duracion (sin negro) if base.duration and base.duration < dur_total: from moviepy.editor import concatenate_videoclips as _ccloop repes = [] acumulado = 0 while acumulado < dur_total and len(repes) < 500: repes.append(base) acumulado += base.duration if acumulado < dur_total: log(f"[Video] AVISO: no se pudo cubrir toda la duracion en bucle (acumulado={acumulado:.1f}s de {dur_total:.1f}s)") base = _ccloop(repes, method="compose").subclip(0, min(dur_total, acumulado)) else: base = base.subclip(0, dur_total) clip = base voz = voz.subclip(0, min(dur_total, voz.duration)) _USAR_VIDEO_ENTERO = True else: _USAR_VIDEO_ENTERO = False else: _USAR_VIDEO_ENTERO = False num_planos = max(int(dur_total / 4) + 1, len(fondos)) fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos] dur_clip = dur_total / num_planos # Si hay escenas con segundos, cada clip dura SUS segundos # Solo usar segundos por escena si hay material asignado en las escenas material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)] usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO if usar_seg_escena: fondos_loop = [m for m,s in material_en_escenas] segundos_loop = [s for m,s in material_en_escenas] clips = [] idx_clip = -1 # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO if _es_rafaga and fondos_loop: dur_rafaga = 0.2 n_necesarias = int(dur_total / dur_rafaga) + 1 fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)] segundos_loop = [dur_rafaga for _ in fondos_loop] usar_seg_escena = True for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []): idx_clip += 1 d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip try: if fp.lower().endswith((".jpg",".jpeg",".png",".webp")): img = PILImage.open(fp).convert("RGB") w, h = img.size bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40)) ratio = min(1080/w, 1920/h) fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS) bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2)) bg.save(fp+"_r.jpg","JPEG",quality=90) ic = IC(fp+"_r.jpg").set_duration(d_este) if efecto in ("zoom","zoom_fundido"): ic = ic.resize(lambda t: 1 + 0.015*t).set_duration(d_este) ic = ic.set_duration(d_este) clips.append(ic) else: vclip = VC(fp).without_audio() vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080) vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920) vclip = vclip.loop(duration=d_este).set_duration(d_este) clips.append(vclip) except Exception as e: log(f"Error clip {fp}: {e}") if not _USAR_VIDEO_ENTERO: hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop) if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video: from moviepy.editor import concatenate_videoclips as _cc clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]] clip = _cc(clips_fx, method="compose", padding=-0.5) else: clip = concatenate_videoclips(clips, method="compose") # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False if _es_manual: suma = sum(s for m,s in escenas_data if m and os.path.exists(m)) if clip.duration < suma: # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro) from moviepy.editor import ImageClip as _ICf ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration) from moviepy.editor import concatenate_videoclips as _ccf clip = _ccf([clip, ultimo.set_pos("center")], method="compose") else: clip = clip.subclip(0, suma) dur_total = suma else: # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar) dur_total = clip.duration voz = voz.subclip(0, min(dur_total, voz.duration)) import re as _re sub_color = subs.get("color","#FFFFFF") sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64) sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78) sub_activo = subs.get("activo",True) # --- SUBTITULOS SINCRONIZADOS POR FRASE --- # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()] # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas segmentos = [] for fr in crudo: pal = fr.split() if len(pal) <= 8: segmentos.append(fr) else: for i in range(0, len(pal), 7): segmentos.append(" ".join(pal[i:i+7])) if not segmentos: segmentos = [guion] # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia) total_pal = sum(max(len(s.split()), 1) for s in segmentos) dur_voz = voz.duration txts = [] if sub_activo: log(f"[Video] generando {len(segmentos)} subtitulos (cada uno lanza ImageMagick, puede tardar si son muchos)...") if len(segmentos) > 150: log(f"[Video] AVISO: guion muy largo -> {len(segmentos)} segmentos de subtitulo. Esto puede tardar varios minutos solo en esta fase.") t0_subs = time.time() t_cursor = 0.0 for idx_s, s in enumerate(segmentos): d = dur_voz * (max(len(s.split()), 1) / total_pal) try: txts.append( TextClip(s.upper(), fontsize=sub_size, color=sub_color, font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None) ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True) ) except Exception as _et: log(f"[Video] subtitulo {idx_s+1} fallo, se omite: {_et}") t_cursor += d if (idx_s+1) % 20 == 0: log(f"[Video] subtitulos: {idx_s+1}/{len(segmentos)} ({time.time()-t0_subs:.0f}s)") log(f"[Video] subtitulos listos: {len(txts)}/{len(segmentos)} en {time.time()-t0_subs:.0f}s") # Mezclar musica de fondo si el cliente la subio import os as _os audio_final = voz if _os.path.exists("musica.mp3"): try: from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC musica = _AC("musica.mp3").volumex(0.18) dur = voz.duration if musica.duration > dur: musica = musica.subclip(0, dur) elif musica.duration < dur: import math reps = math.ceil(dur / musica.duration) from moviepy.editor import concatenate_audioclips musica = concatenate_audioclips([_AC("musica.mp3").volumex(0.18) for _ in range(reps)]).subclip(0, dur) audio_final = CompositeAudioClip([voz.volumex(1.0), musica]) except Exception as _em: log(f"Error mezcla musica: {_em}") audio_final = voz _vid = CompositeVideoClip([clip]+txts) try: _vd = getattr(_vid, "duration", None) _ad = getattr(audio_final, "duration", None) if _vd and _ad: if _ad > _vd + 0.05: # audio mas largo que el video -> NO recortar la narracion: # congelar el ultimo fotograma hasta cubrir toda la voz extra = _ad - _vd ultimo_frame = _vid.to_ImageClip(t=max(_vd - 0.1, 0), duration=extra).set_pos("center") _vid = concatenate_videoclips([_vid, ultimo_frame], method="compose") _vd = _vid.duration log(f"[Video] video extendido a {_vd:.1f}s (voz duraba {_ad:.1f}s) para no cortar la narracion") elif _vd > _ad: # video mas largo que el audio -> rellenar con silencio hasta el final from moviepy.editor import CompositeAudioClip as _CAC audio_final = _CAC([audio_final]).set_duration(_vd) log(f"[Video] audio rellenado con silencio a {_vd:.1f}s (era {_ad:.1f}s)") # alinear exacto para que ffmpeg nunca lea fuera de rango audio_final = audio_final.set_duration(_vd) except Exception as _ea: log(f"[Video] ajuste audio: {_ea}") final = _vid.set_audio(audio_final) try: if getattr(_vid, "duration", None): final = final.set_duration(_vid.duration) except Exception: pass import os as _os2 for _f in ("preview.mp4", "preview_tmp.mp4"): if _os2.path.exists(_f): _os2.remove(_f) try: log(f"[Video] entrando a write_videofile | duracion={getattr(final,'duration',None)}") t0_render = time.time() final.write_videofile("preview_tmp.mp4", fps=24, codec="libx264", preset="fast", logger=None, threads=4, audio_codec="aac", ffmpeg_params=["-pix_fmt", "yuv420p", "-movflags", "+faststart"]) log(f"[Video] render final terminado en {time.time()-t0_render:.0f}s") except Exception as _ew: log(f"[Video] write_videofile FALLO: {_ew}") raise if not _os2.path.exists("preview_tmp.mp4"): raise RuntimeError("write_videofile no genero preview_tmp.mp4 (revisa audio/duracion)") _os2.rename("preview_tmp.mp4", "preview.mp4") return "preview.mp4" # ========== ENDPOINTS ========== @app.post("/login") async def ep_login(request: Request): d = await request.json() clave = d.get("clave","").strip() if verificar_clave(clave): return {"ok": True} return JSONResponse({"error":"Clave incorrecta"}, status_code=401) @app.post("/generar-guion") async def ep_guion(request: Request): d = await request.json() texto = d.get("texto","").strip() estilo = d.get("estilo","narrativo") duracion = int(d.get("duracion", 60) or 60) parte = d.get("parte","auto") # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido completo = ESTADO.get("texto_completo","") if completo and len(completo) > 6000: if parte == "principio": texto = completo[:6000] elif parte == "mitad": m = len(completo)//2 texto = completo[m-3000:m+3000] elif parte == "final": texto = completo[-6000:] else: # auto = trozo al azar import random as _r ini = _r.randint(0, max(0, len(completo)-6000)) texto = completo[ini:ini+6000] if not texto: return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400) try: guion = generar_guion(texto, estilo, duracion) ESTADO["guion"] = guion return {"guion": guion} except Exception as e: return JSONResponse({"error": str(e)[:100]}, status_code=500) @app.post("/subir-material") async def ep_material(file: UploadFile = File(...)): try: import subprocess as _sp, os as _os ext = file.filename.split(".")[-1].lower() idx = len(ESTADO['archivos']) if ext in ("mp4","mov","webm","avi","mkv","m4v"): crudo = f"crudo_{idx}.{ext}" with open(crudo,"wb") as f: f.write(await file.read()) nombre = f"media_{idx}.mp4" # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros) r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True) if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000: try: _os.remove(crudo) except: pass else: nombre = crudo else: nombre = f"media_{idx}.{ext}" with open(nombre,"wb") as f: f.write(await file.read()) ESTADO["archivos"].append(nombre) return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre} except Exception as e: return JSONResponse({"error": str(e)[:100]}, status_code=500) @app.post("/generar-voz") async def ep_voz(request: Request): d = await request.json() guion = d.get("guion","").strip() if not guion: return JSONResponse({"error":"No hay guion"}, status_code=400) ESTADO["guion"] = guion try: await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais","")) ESTADO["voz_lista"] = True return {"ok": True} except Exception as e: return JSONResponse({"error": str(e)[:100]}, status_code=500) @app.post("/subir-voz-grabada") async def ep_voz_grabada(file: UploadFile = File(...)): try: import subprocess contenido = await file.read() with open("voz_grabada_raw","wb") as f: f.write(contenido) # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg) subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True) import os as _os if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000: ESTADO["voz_lista"] = True return {"ok": True} return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500) except Exception as e: return JSONResponse({"error": str(e)[:120]}, status_code=500) @app.post("/subir-musica") async def ep_musica(file: UploadFile = File(...)): try: import subprocess, os as _os contenido = await file.read() with open("musica_raw","wb") as f: f.write(contenido) subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True) if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000: ESTADO["musica"] = True return {"ok": True} return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500) except Exception as e: return JSONResponse({"error": str(e)[:120]}, status_code=500) @app.post("/generar-musica-ia") async def ep_musica_ia(request: Request): try: d = await request.json() nicho = d.get("nicho", "espiritual") duracion = int(d.get("duracion", 20)) url_fabrica = os.environ.get("URL_FABRICA_MUSICA", "").rstrip("/") if not url_fabrica: return JSONResponse({"error": "Secret URL_FABRICA_MUSICA no configurado"}, status_code=500) PROMPTS = { "espiritual": "mystical ambient music, spiritual journey, ethereal choir, no lyrics", "numerologia": "meditative orchestral music, sacred geometry, soft piano and strings", "motivacional": "epic cinematic music, uplifting, orchestral, no vocals", "terror": "dark atmospheric tension, horror underscore, eerie strings", "naturaleza": "peaceful nature sounds, light acoustic guitar, calm", "default": "cinematic background music, neutral mood, no vocals", } prompt = next((v for k, v in PROMPTS.items() if k in nicho.lower()), PROMPTS["default"]) from gradio_client import Client client = Client(url_fabrica) ruta = client.predict(prompt, duracion, api_name="/generar_musica") if isinstance(ruta, (list, tuple)) and ruta: ruta = ruta[0] if isinstance(ruta, dict): ruta = ruta.get("path") or ruta.get("name") or "" if not ruta or not os.path.exists(ruta): log(f"[Musica] sin fichero valido: {ruta}") return JSONResponse({"error": "La Fabrica no devolvio audio"}, status_code=500) with open(ruta, "rb") as fi, open("musica.mp3", "wb") as fo: fo.write(fi.read()) ESTADO["musica"] = True log(f"[Musica] OK -> {ruta}") return {"ok": True} except Exception as e: log(f"[Musica] ERROR: {e}") return JSONResponse({"error": str(e)[:200]}, status_code=500) @app.get("/musica-lista") def ep_musica_lista(): if os.path.exists("musica.mp3"): return {"listo": True, "mtime": os.path.getmtime("musica.mp3")} return {"listo": False, "mtime": 0} @app.get("/audio") def ep_audio(): if os.path.exists("voz.mp3"): return FileResponse("voz.mp3", media_type="audio/mpeg") return JSONResponse({"error":"sin audio"}, status_code=404) @app.post("/generar-video") async def ep_video(request: Request): d = await request.json() guion = d.get("guion","").strip() or ESTADO["guion"] if not os.path.exists("voz.mp3"): return JSONResponse({"error":"Genera la voz primero"}, status_code=400) subs = { "color": d.get("sub_color","#FFFFFF"), "tamano": d.get("sub_tamano","mediano"), "posicion": d.get("sub_posicion","abajo"), "activo": d.get("sub_activo", True), } efecto = d.get("efecto","ninguno") escenas = d.get("escenas", []) # Modo híbrido: rellenar escenas vacías con clips de Veo if ESTADO["archivos"]: # Si hay archivos subidos, los asignamos a las escenas automáticas for idx, e in enumerate(escenas): mat = str(e.get("material", "")).strip() if not mat or mat == "automático" or mat == "automatico": # Usamos tus archivos de forma circular si hay menos que escenas e["material"] = ESTADO["archivos"][idx % len(ESTADO["archivos"])] if not ESTADO["archivos"]: from nichos import detectar_nicho nicho_detectado = detectar_nicho(guion) print(f"[Fenix] Nicho detectado: {nicho_detectado}", flush=True) for e in escenas: if not e.get("prompt","").strip(): e["prompt"] = prompt_visual_auto(e.get("texto","") or e.get("narracion","")) print("[Fenix] Prompt auto generado", flush=True) for e in escenas: if e.get("prompt","").strip() and not e.get("prompt_manual","").strip(): e["prompt_manual"] = e["prompt"].strip() motor = d.get("motor","auto") escenas = generar_clips_escenas(escenas, nicho=nicho_detectado, motor=motor) con_material = sum(1 for e in escenas if str(e.get("material","")).strip()) if con_material < len(escenas): log(f"[Fenix] AVISO: {len(escenas)} escenas en el guion pero solo {con_material} tienen video generado (limite VEO_MAX_CLIPS). Las demas usaran el ultimo fotograma congelado.") # Lista de (material, segundos) de cada escena, en orden # Pasar el prompt visual del usuario a cada escena para que los servicios lo usen for e in escenas: if e.get("prompt","").strip() and not e.get("prompt_manual","").strip(): e["prompt_manual"] = e["prompt"].strip() escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas] material_escenas = [m for m,s in escenas_data if m] try: loop = asyncio.get_event_loop() await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data) ESTADO["video_listo"] = True return {"ok": True} except Exception as e: import traceback tb = traceback.format_exc() print("ERROR VIDEO:", tb, flush=True) return JSONResponse({"error": str(e)[:300]}, status_code=500) @app.get("/video-listo") def ep_video_listo(): import os as _os if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000: return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")} return {"listo": False} @app.get("/video") def ep_video_file(): if os.path.exists("preview.mp4"): return FileResponse("preview.mp4", media_type="video/mp4") return JSONResponse({"error":"sin video"}, status_code=404) @app.post("/reset") def ep_reset(): for f in glob.glob("media_*")+glob.glob("fondo*")+glob.glob("leonardo_*")+["voz.mp3","preview.mp4","musica.mp3","musica_raw"]: try: os.remove(f) except: pass ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False return {"ok": True} @app.post("/subir-pdf") async def ep_pdf(file: UploadFile = File(...)): try: contenido = await file.read() with open("temp.pdf","wb") as f: f.write(contenido) from pypdf import PdfReader reader = PdfReader("temp.pdf") texto = "" for pagina in reader.pages: texto += (pagina.extract_text() or "") + "\n" texto = texto.strip() if not texto: return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400) ESTADO["texto_completo"] = texto # Mostrar preview de los primeros 6000 al usuario return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)} except Exception as e: return JSONResponse({"error": str(e)[:120]}, status_code=500) # ========== INTERFAZ ========== @app.get("/", response_class=HTMLResponse) def home(): return """ Fenix Hybrid Engine
Fenix Hybrid Engine

Introduce tu clave de acceso

Fenix Hybrid Engine

Motor de produccion asistida. La IA acelera, tu tienes el control.
1. Ingesta de conocimiento
Sube un PDF (extrae el texto solo) o pega tu contenido:
Duracion del video: Estilo de narrativa: Motor de video: Que parte del documento usar (si subiste PDF largo):
2. Edicion del guion control humano
Edita el guion a tu gusto antes de continuar:
3. Tu material
Sube tus imagenes o videos (uno a uno):
Sin material aun (usara fondos automaticos)
4. Voz
Musica de fondo:
5. Subtitulos y Montaje
Efecto de video:
6. Publicacion
"""