Tu Nombre commited on
Commit
c96c8af
·
1 Parent(s): 2c9c03f

motor ltx

Browse files
__pycache__/leonardo_service.cpython-313.pyc ADDED
Binary file (12.1 kB). View file
 
__pycache__/veo_service.cpython-313.pyc ADDED
Binary file (13.2 kB). View file
 
app.py CHANGED
@@ -13,8 +13,15 @@ try:
13
  from leonardo_service import generar_clips_escenas as generar_leonardo
14
  except Exception:
15
  def generar_leonardo(escenas): return None
 
 
 
 
16
  def generar_clips_escenas(escenas):
17
  import copy
 
 
 
18
  res = generar_runway(copy.deepcopy(escenas))
19
  if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
20
  print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
 
13
  from leonardo_service import generar_clips_escenas as generar_leonardo
14
  except Exception:
15
  def generar_leonardo(escenas): return None
16
+ try:
17
+ from ltx_service import generar_clips_escenas as generar_ltx
18
+ except Exception:
19
+ def generar_ltx(escenas): return None
20
  def generar_clips_escenas(escenas):
21
  import copy
22
+ res = generar_ltx(copy.deepcopy(escenas))
23
+ if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res
24
+ print("[Cerebro] LTX no disponible. Saltando a Runway...", flush=True)
25
  res = generar_runway(copy.deepcopy(escenas))
26
  if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
27
  print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
app_backup_20260608.py ADDED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, glob, random, asyncio, base64
2
+ from fastapi import FastAPI, Request, UploadFile, File
3
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
4
+ from groq import Groq
5
+ import edge_tts, requests
6
+
7
+ app = FastAPI()
8
+
9
+ 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]
10
+
11
+ CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026")
12
+
13
+ def verificar_clave(clave):
14
+ return clave == CLAVE_ACCESO
15
+
16
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
17
+
18
+ # Estado de la sesion (guarda guion editado y material entre pasos)
19
+ ESTADO = {
20
+ "guion": "",
21
+ "archivos": [],
22
+ "voz_lista": False,
23
+ "video_listo": False,
24
+ }
25
+
26
+ ESTILOS = {
27
+ "narrativo": "narrativo y emocional, como contando una historia",
28
+ "educativo": "educativo y claro, explicando con autoridad",
29
+ "motivador": "motivador e inspirador, que mueva a la accion",
30
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
31
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
32
+ }
33
+
34
+ VOCES = {
35
+ "es": {
36
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
37
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
38
+ },
39
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
40
+ }
41
+
42
+ def log(m): print(m, flush=True)
43
+
44
+ # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua.
45
+ # Si falla, devuelve el clip original (nunca rompe el montaje).
46
+ def quitar_marca_meta(path, _cache={}):
47
+ exts_v = (".mp4",".mov",".webm",".avi",".mkv")
48
+ exts_i = (".jpg",".jpeg",".png",".webp")
49
+ low = str(path).lower()
50
+ if not low.endswith(exts_v + exts_i):
51
+ return path
52
+ if path in _cache:
53
+ return _cache[path]
54
+ try:
55
+ import subprocess
56
+ dims = subprocess.run(
57
+ ["ffprobe","-v","error","-select_streams","v:0",
58
+ "-show_entries","stream=width,height","-of","csv=p=0:s=x", path],
59
+ capture_output=True, text=True).stdout.strip()
60
+ W, H = [int(v) for v in dims.split("x")[:2]]
61
+ # Tapar marca "Veo" (banda negra abajo-derecha) con "Teshua" dorado
62
+ es_video = low.endswith(exts_v)
63
+ salida = path + ("_clean.mp4" if es_video else "_clean.png")
64
+ fs = max(22, int(H * 0.030))
65
+ pad = int(fs * 0.5)
66
+ # Posicion: pegado abajo-derecha, dentro de la banda negra donde aparece "Veo"
67
+ tx = W - int(W * 0.02) # x: desde la derecha
68
+ ty = H - int(H * 0.018) # y: casi en el borde inferior
69
+ # 1) Caja negra que tapa "Veo" completamente
70
+ # 2) Borde dorado alrededor
71
+ # 3) Texto "Teshua" dorado encima
72
+ vf = (
73
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=black@1:t=fill,"
74
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=0xFFD700@1:t=2,"
75
+ f"drawtext=text='Teshuá':fontsize={fs}:fontcolor=0xFFD700:x=W-tw-{int(W*0.03)}:y=H-th-{int(H*0.012)}:font=serif:borderw=2:bordercolor=black"
76
+ )
77
+ cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf]
78
+ cmd += (["-c:a","copy",salida] if es_video else [salida])
79
+ subprocess.run(cmd, capture_output=True)
80
+ if os.path.exists(salida) and os.path.getsize(salida) > 0:
81
+ log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if os.path.exists('logo.png') else 'no'})")
82
+ _cache[path] = salida; return salida
83
+ except Exception as e:
84
+ log(f"marca error: {e}")
85
+ _cache[path] = path
86
+ return path
87
+
88
+ # ========== GUION ==========
89
+ def generar_guion(texto_base, estilo, duracion=60):
90
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
91
+ palabras = int(duracion * 2.4)
92
+ last_err = None
93
+ MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"]
94
+ 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."
95
+ # Probar cada modelo con cada key hasta que uno funcione
96
+ for modelo in MODELOS:
97
+ for k in GROQ_KEYS:
98
+ try:
99
+ client = Groq(api_key=k)
100
+ res = client.chat.completions.create(
101
+ messages=[{"role":"user","content":prompt}],
102
+ model=modelo, temperature=0.85
103
+ )
104
+ return res.choices[0].message.content.strip()
105
+ except Exception as e:
106
+ last_err = e
107
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
108
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
109
+ continue
110
+ raise last_err if last_err else Exception("Sin keys Groq")
111
+
112
+ # ========== VOZ ==========
113
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
114
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
115
+ if pais:
116
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
117
+ cod = cods.get(pais.lower(),"")
118
+ if cod:
119
+ filt = [v for v in voces if f"-{cod}-" in v]
120
+ if filt: voces = filt
121
+ voz_sel = random.choice(voces)
122
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
123
+ return voz_sel
124
+
125
+ # ========== VIDEO ==========
126
+ def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None):
127
+ subs = subs or {}
128
+ material_escenas = material_escenas or []
129
+ escenas_data = escenas_data or []
130
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
131
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
132
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
133
+
134
+ voz = AC("voz.mp3")
135
+ # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales
136
+ if material_escenas:
137
+ fondos = [m for m in material_escenas if os.path.exists(m)]
138
+ else:
139
+ fondos = []
140
+ # Respaldo: si no hay fondos de escenas validos, usar el material general
141
+ if not fondos:
142
+ fondos = archivos[:8] if archivos else []
143
+
144
+ # Si no hay material propio, fondo de Pexels
145
+ if not fondos:
146
+ try:
147
+ h = {"Authorization": PEXELS_KEY}
148
+ r = requests.get("https://api.pexels.com/videos/search?query=abstract&per_page=8", headers=h)
149
+ for i, v in enumerate(r.json().get("videos", [])[:6]):
150
+ u = v["video_files"][0]["link"]
151
+ with open(f"fondo{i}.mp4","wb") as f: f.write(requests.get(u, timeout=30).content)
152
+ fondos.append(f"fondo{i}.mp4")
153
+ except Exception as e:
154
+ log(f"Error pexels: {e}")
155
+
156
+ # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar ===
157
+ fondos = [quitar_marca_meta(f) for f in fondos]
158
+ if material_escenas:
159
+ material_escenas = [quitar_marca_meta(m) for m in material_escenas]
160
+ if escenas_data:
161
+ escenas_data = [(quitar_marca_meta(m) if m else m, s) for m, s in escenas_data]
162
+
163
+ # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz
164
+ if escenas_data:
165
+ suma_seg = sum(s for m,s in escenas_data)
166
+ dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200)
167
+ else:
168
+ dur_total = min(voz.duration, 1200)
169
+
170
+ # Si el material son SOLO videos, usar el video entero de fondo (no trocear)
171
+ solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos)
172
+ if solo_videos:
173
+ from moviepy.editor import concatenate_videoclips as _ccv
174
+ vclips = []
175
+ for fp in fondos:
176
+ try:
177
+ vc = VC(fp).without_audio()
178
+ vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080)
179
+ vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920)
180
+ vclips.append(vc)
181
+ except Exception as e:
182
+ log(f"Error video {fp}: {e}")
183
+ if vclips:
184
+ base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0]
185
+ # Repetir el video en bucle hasta cubrir toda la duracion (sin negro)
186
+ if base.duration < dur_total:
187
+ from moviepy.editor import concatenate_videoclips as _ccloop
188
+ repes = []
189
+ acumulado = 0
190
+ while acumulado < dur_total:
191
+ repes.append(base)
192
+ acumulado += base.duration
193
+ base = _ccloop(repes, method="compose").subclip(0, dur_total)
194
+ else:
195
+ base = base.subclip(0, dur_total)
196
+ clip = base
197
+ voz = voz.subclip(0, min(dur_total, voz.duration))
198
+ _USAR_VIDEO_ENTERO = True
199
+ else:
200
+ _USAR_VIDEO_ENTERO = False
201
+ else:
202
+ _USAR_VIDEO_ENTERO = False
203
+
204
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
205
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
206
+ dur_clip = dur_total / num_planos
207
+ # Si hay escenas con segundos, cada clip dura SUS segundos
208
+ # Solo usar segundos por escena si hay material asignado en las escenas
209
+ material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)]
210
+ usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO
211
+ if usar_seg_escena:
212
+ fondos_loop = [m for m,s in material_en_escenas]
213
+ segundos_loop = [s for m,s in material_en_escenas]
214
+ clips = []
215
+ idx_clip = -1
216
+ # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz
217
+ _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO
218
+ if _es_rafaga and fondos_loop:
219
+ dur_rafaga = 0.2
220
+ n_necesarias = int(dur_total / dur_rafaga) + 1
221
+ fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)]
222
+ segundos_loop = [dur_rafaga for _ in fondos_loop]
223
+ usar_seg_escena = True
224
+ for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []):
225
+ idx_clip += 1
226
+ d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip
227
+ try:
228
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
229
+ img = PILImage.open(fp).convert("RGB")
230
+ w, h = img.size
231
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
232
+ ratio = min(1080/w, 1920/h)
233
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
234
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
235
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
236
+ ic = IC(fp+"_r.jpg").set_duration(d_este)
237
+ if efecto in ("zoom","zoom_fundido"):
238
+ ic = ic.resize(lambda t: 1 + 0.04*t).set_duration(d_este)
239
+ ic = ic.set_duration(d_este)
240
+ clips.append(ic)
241
+ else:
242
+ vclip = VC(fp).without_audio()
243
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
244
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
245
+ vclip = vclip.loop(duration=d_este).set_duration(d_este)
246
+ clips.append(vclip)
247
+ except Exception as e:
248
+ log(f"Error clip {fp}: {e}")
249
+
250
+ if not _USAR_VIDEO_ENTERO:
251
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
252
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
253
+ from moviepy.editor import concatenate_videoclips as _cc
254
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
255
+ clip = _cc(clips_fx, method="compose", padding=-0.5)
256
+ else:
257
+ clip = concatenate_videoclips(clips, method="compose")
258
+ # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta
259
+ _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False
260
+ if _es_manual:
261
+ suma = sum(s for m,s in escenas_data if m and os.path.exists(m))
262
+ if clip.duration < suma:
263
+ # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro)
264
+ from moviepy.editor import ImageClip as _ICf
265
+ ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration)
266
+ from moviepy.editor import concatenate_videoclips as _ccf
267
+ clip = _ccf([clip, ultimo.set_pos("center")], method="compose")
268
+ else:
269
+ clip = clip.subclip(0, suma)
270
+ dur_total = suma
271
+ else:
272
+ # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar)
273
+ dur_total = clip.duration
274
+ voz = voz.subclip(0, min(dur_total, voz.duration))
275
+
276
+ import re as _re
277
+ sub_color = subs.get("color","#FFFFFF")
278
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
279
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
280
+ sub_activo = subs.get("activo",True)
281
+
282
+ # --- SUBTITULOS SINCRONIZADOS POR FRASE ---
283
+ # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea
284
+ crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()]
285
+ # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas
286
+ segmentos = []
287
+ for fr in crudo:
288
+ pal = fr.split()
289
+ if len(pal) <= 8:
290
+ segmentos.append(fr)
291
+ else:
292
+ for i in range(0, len(pal), 7):
293
+ segmentos.append(" ".join(pal[i:i+7]))
294
+ if not segmentos:
295
+ segmentos = [guion]
296
+ # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia)
297
+ total_pal = sum(max(len(s.split()), 1) for s in segmentos)
298
+ dur_voz = voz.duration
299
+ txts = []
300
+ if sub_activo:
301
+ t_cursor = 0.0
302
+ for s in segmentos:
303
+ d = dur_voz * (max(len(s.split()), 1) / total_pal)
304
+ txts.append(
305
+ TextClip(s.upper(), fontsize=sub_size, color=sub_color,
306
+ font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
307
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
308
+ ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True)
309
+ )
310
+ t_cursor += d
311
+
312
+ # Mezclar musica de fondo si el cliente la subio
313
+ import os as _os
314
+ audio_final = voz
315
+ if _os.path.exists("musica.mp3"):
316
+ try:
317
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
318
+ musica = _AC("musica.mp3").volumex(0.18) # musica bajita para no tapar la voz
319
+ if musica.duration > voz.duration:
320
+ musica = musica.subclip(0, voz.duration)
321
+ else:
322
+ musica = musica.audio_loop(duration=voz.duration)
323
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
324
+ except Exception as _em:
325
+ log(f"Error mezcla musica: {_em}")
326
+ audio_final = voz
327
+ final = CompositeVideoClip([clip]+txts).set_audio(audio_final)
328
+ import os as _os2
329
+ if _os2.path.exists("preview.mp4"):
330
+ _os2.remove("preview.mp4")
331
+ final.write_videofile("preview_tmp.mp4", fps=20, codec="libx264", preset="ultrafast", logger=None, threads=4, audio_codec="aac")
332
+ # Renombrar solo cuando esta 100% terminado (evita cargar video a medias)
333
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
334
+ return "preview.mp4"
335
+
336
+ # ========== ENDPOINTS ==========
337
+ @app.post("/login")
338
+ async def ep_login(request: Request):
339
+ d = await request.json()
340
+ clave = d.get("clave","").strip()
341
+ if verificar_clave(clave):
342
+ return {"ok": True}
343
+ return JSONResponse({"error":"Clave incorrecta"}, status_code=401)
344
+
345
+ @app.post("/generar-guion")
346
+ async def ep_guion(request: Request):
347
+ d = await request.json()
348
+ texto = d.get("texto","").strip()
349
+ estilo = d.get("estilo","narrativo")
350
+ duracion = int(d.get("duracion", 60) or 60)
351
+ parte = d.get("parte","auto")
352
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
353
+ completo = ESTADO.get("texto_completo","")
354
+ if completo and len(completo) > 6000:
355
+ if parte == "principio":
356
+ texto = completo[:6000]
357
+ elif parte == "mitad":
358
+ m = len(completo)//2
359
+ texto = completo[m-3000:m+3000]
360
+ elif parte == "final":
361
+ texto = completo[-6000:]
362
+ else: # auto = trozo al azar
363
+ import random as _r
364
+ ini = _r.randint(0, max(0, len(completo)-6000))
365
+ texto = completo[ini:ini+6000]
366
+ if not texto:
367
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
368
+ try:
369
+ guion = generar_guion(texto, estilo, duracion)
370
+ ESTADO["guion"] = guion
371
+ return {"guion": guion}
372
+ except Exception as e:
373
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
374
+
375
+ @app.post("/subir-material")
376
+ async def ep_material(file: UploadFile = File(...)):
377
+ try:
378
+ import subprocess as _sp, os as _os
379
+ ext = file.filename.split(".")[-1].lower()
380
+ idx = len(ESTADO['archivos'])
381
+ if ext in ("mp4","mov","webm","avi","mkv","m4v"):
382
+ crudo = f"crudo_{idx}.{ext}"
383
+ with open(crudo,"wb") as f:
384
+ f.write(await file.read())
385
+ nombre = f"media_{idx}.mp4"
386
+ # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros)
387
+ r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True)
388
+ if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000:
389
+ try: _os.remove(crudo)
390
+ except: pass
391
+ else:
392
+ nombre = crudo
393
+ else:
394
+ nombre = f"media_{idx}.{ext}"
395
+ with open(nombre,"wb") as f:
396
+ f.write(await file.read())
397
+ ESTADO["archivos"].append(nombre)
398
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
399
+ except Exception as e:
400
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
401
+
402
+ @app.post("/generar-voz")
403
+ async def ep_voz(request: Request):
404
+ d = await request.json()
405
+ guion = d.get("guion","").strip()
406
+ if not guion:
407
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
408
+ ESTADO["guion"] = guion
409
+ try:
410
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
411
+ ESTADO["voz_lista"] = True
412
+ return {"ok": True}
413
+ except Exception as e:
414
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
415
+
416
+
417
+ @app.post("/subir-voz-grabada")
418
+ async def ep_voz_grabada(file: UploadFile = File(...)):
419
+ try:
420
+ import subprocess
421
+ contenido = await file.read()
422
+ with open("voz_grabada_raw","wb") as f:
423
+ f.write(contenido)
424
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
425
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
426
+ import os as _os
427
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
428
+ ESTADO["voz_lista"] = True
429
+ return {"ok": True}
430
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
431
+ except Exception as e:
432
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
433
+
434
+
435
+ @app.post("/subir-musica")
436
+ async def ep_musica(file: UploadFile = File(...)):
437
+ try:
438
+ import subprocess, os as _os
439
+ contenido = await file.read()
440
+ with open("musica_raw","wb") as f:
441
+ f.write(contenido)
442
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
443
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
444
+ ESTADO["musica"] = True
445
+ return {"ok": True}
446
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
447
+ except Exception as e:
448
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
449
+
450
+ @app.get("/audio")
451
+ def ep_audio():
452
+ if os.path.exists("voz.mp3"):
453
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
454
+ return JSONResponse({"error":"sin audio"}, status_code=404)
455
+
456
+ @app.post("/generar-video")
457
+ async def ep_video(request: Request):
458
+ d = await request.json()
459
+ guion = d.get("guion","").strip() or ESTADO["guion"]
460
+ if not os.path.exists("voz.mp3"):
461
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
462
+ subs = {
463
+ "color": d.get("sub_color","#FFFFFF"),
464
+ "tamano": d.get("sub_tamano","mediano"),
465
+ "posicion": d.get("sub_posicion","abajo"),
466
+ "activo": d.get("sub_activo", True),
467
+ }
468
+ efecto = d.get("efecto","ninguno")
469
+ escenas = d.get("escenas", [])
470
+ # Lista de (material, segundos) de cada escena, en orden
471
+ escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas]
472
+ material_escenas = [m for m,s in escenas_data if m]
473
+ try:
474
+ loop = asyncio.get_event_loop()
475
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data)
476
+ ESTADO["video_listo"] = True
477
+ return {"ok": True}
478
+ except Exception as e:
479
+ import traceback
480
+ tb = traceback.format_exc()
481
+ print("ERROR VIDEO:", tb, flush=True)
482
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
483
+
484
+
485
+ @app.get("/video-listo")
486
+ def ep_video_listo():
487
+ import os as _os
488
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
489
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
490
+ return {"listo": False}
491
+
492
+ @app.get("/video")
493
+ def ep_video_file():
494
+ if os.path.exists("preview.mp4"):
495
+ return FileResponse("preview.mp4", media_type="video/mp4")
496
+ return JSONResponse({"error":"sin video"}, status_code=404)
497
+
498
+ @app.post("/reset")
499
+ def ep_reset():
500
+ for f in glob.glob("media_*")+glob.glob("fondo*")+["voz.mp3","preview.mp4"]:
501
+ try: os.remove(f)
502
+ except: pass
503
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
504
+ return {"ok": True}
505
+
506
+ @app.post("/subir-pdf")
507
+ async def ep_pdf(file: UploadFile = File(...)):
508
+ try:
509
+ contenido = await file.read()
510
+ with open("temp.pdf","wb") as f:
511
+ f.write(contenido)
512
+ from pypdf import PdfReader
513
+ reader = PdfReader("temp.pdf")
514
+ texto = ""
515
+ for pagina in reader.pages:
516
+ texto += (pagina.extract_text() or "") + "\n"
517
+ texto = texto.strip()
518
+ if not texto:
519
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
520
+ ESTADO["texto_completo"] = texto
521
+ # Mostrar preview de los primeros 6000 al usuario
522
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
523
+ except Exception as e:
524
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
525
+
526
+ # ========== INTERFAZ ==========
527
+ @app.get("/", response_class=HTMLResponse)
528
+ def home():
529
+ return """<!DOCTYPE html>
530
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
531
+ <title>Fenix Hybrid Engine</title>
532
+ <style>
533
+ *{box-sizing:border-box;margin:0;padding:0}
534
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
535
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
536
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
537
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
538
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
539
+ textarea,select,input{width:100%;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:8px;padding:12px;font-size:.95rem;font-family:inherit;margin-bottom:10px}
540
+ textarea{min-height:90px;resize:vertical}
541
+ button{background:linear-gradient(90deg,#ff6b00,#ffb700);color:#000;border:none;padding:14px 20px;border-radius:8px;font-weight:800;font-size:1rem;cursor:pointer;width:100%;margin-top:6px}
542
+ button:disabled{opacity:.4}
543
+ .btn2{background:#222;color:#fff;border:1px solid #444}
544
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
545
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
546
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
547
+ .row{display:flex;gap:8px}.row>*{flex:1}
548
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
549
+
550
+ .spinner{display:inline-block;width:24px;height:24px;border:3px solid #333;border-top:3px solid #ffb700;border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle;margin-right:8px}
551
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
552
+ .tick{display:inline-block;width:24px;height:24px;background:#00c853;border-radius:50%;color:#fff;text-align:center;line-height:24px;font-weight:900;margin-right:8px;vertical-align:middle}
553
+ </style></head><body>
554
+ <div id="loginOverlay" style="position:fixed;inset:0;background:#0a0a0a;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px">
555
+ <div style="font-size:2rem;font-weight:800;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px">Fenix Hybrid Engine</div>
556
+ <p style="color:#888;margin-bottom:24px;font-size:.95rem">Introduce tu clave de acceso</p>
557
+ <input type="password" id="claveInput" placeholder="Clave de acceso" style="width:100%;max-width:300px;background:#111;color:#fff;border:1px solid #333;border-radius:10px;padding:14px;font-size:1rem;margin-bottom:12px" onkeydown="if(event.key==='Enter')hacerLogin()">
558
+ <button onclick="hacerLogin()" style="width:100%;max-width:300px;background:linear-gradient(135deg,#ff6b00,#ff3d00);color:#fff;border:none;padding:14px;border-radius:10px;font-weight:800;font-size:1rem;cursor:pointer">Entrar</button>
559
+ <div id="loginMsg" style="color:#ff5252;margin-top:12px;font-size:.9rem;min-height:18px"></div>
560
+ </div>
561
+ <script>
562
+ async function hacerLogin(){
563
+ var clave=document.getElementById("claveInput").value;
564
+ var msg=document.getElementById("loginMsg");
565
+ msg.textContent="Comprobando...";msg.style.color="#ffb700";
566
+ try{
567
+ var r=await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clave:clave})});
568
+ var d=await r.json();
569
+ if(d.ok){document.getElementById("loginOverlay").style.display="none";}
570
+ else{msg.textContent="Clave incorrecta";msg.style.color="#ff5252";}
571
+ }catch(e){msg.textContent="Error";msg.style.color="#ff5252";}
572
+ }
573
+ </script>
574
+
575
+ <h1>Fenix Hybrid Engine</h1>
576
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
577
+ <button onclick="var p=document.getElementById('panelAyuda');p.style.display=(p.style.display=='none'||!p.style.display)?'block':'none';" style="background:#ff6b00;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-weight:700;cursor:pointer;margin:10px 0">Como usar (pulsa aqui)</button>
578
+ <div id="panelAyuda" style="display:none;background:#1a1a1a;border:1px solid #ff6b00;border-radius:10px;padding:16px;margin:10px 0;line-height:1.6">
579
+ <b>COMO HACER TU VIDEO (paso a paso):</b><br><br>
580
+ <b>1.</b> Sube un PDF o pega tu texto.<br>
581
+ <b>2.</b> Elige la duracion (15s, 30s, 45s, 1 o 2 min) y el estilo. Pulsa Generar Guion.<br>
582
+ <b>3.</b> Si quieres, edita el guion a tu gusto.<br>
583
+ <b>4.</b> Pulsa Dividir en escenas.<br>
584
+ <b>5.</b> Sube tus imagenes o videos.<br>
585
+ <b>6.</b> Genera la voz. Los segundos de cada escena se ponen SOLOS segun la voz, y abajo veras el total.<br>
586
+ <b>7.</b> Elige color de subtitulos y efecto si quieres.<br>
587
+ <b>8.</b> Pulsa Generar Video y descargalo. Sale perfecto, sin pantalla negra.<br><br>
588
+ <b>DOS MODOS DE MONTAR:</b><br>
589
+ - <b>AUTOMATICO (facil):</b> deja el material en automatico. El sistema reparte las imagenes solo. Ideal para ir rapido.<br>
590
+ - <b>MANUAL (control total):</b> asigna una imagen a cada escena. Tu decides que imagen va en cada parte.<br><br>
591
+ <b>CONSEJO IMPORTANTE:</b> los segundos salen solos al generar la voz, no tienes que calcular nada. Si quieres que una escena dure mas o menos, NO toques los segundos: edita su TEXTO (hazlo mas largo o mas corto) y vuelve a generar la voz. Los segundos se ajustan solos. Asi es mas facil y siempre cuadra.<br>
592
+ </div>
593
+
594
+ <div class="fase">
595
+ <div class="fase-t">1. Ingesta de conocimiento</div>
596
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
597
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
598
+ <div class="msg" id="msgPdf"></div>
599
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
600
+ <span class="lbl">Duracion del video:</span>
601
+ <select id="duracion">
602
+ <option value="15">15 segundos</option>
603
+ <option value="30">30 segundos</option>
604
+ <option value="45">45 segundos</option>
605
+ <option value="60">1 minuto</option>
606
+ <option value="120">2 minutos</option>
607
+ </select>
608
+ <span class="lbl">Estilo de narrativa:</span>
609
+ <select id="estilo">
610
+ <option value="narrativo">Narrativo y emocional</option>
611
+ <option value="educativo">Educativo y claro</option>
612
+ <option value="motivador">Motivador e inspirador</option>
613
+ <option value="publicitario">Publicitario y persuasivo</option>
614
+ <option value="misterio">Misterio e intriga</option>
615
+ </select>
616
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
617
+ <select id="parte">
618
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
619
+ <option value="principio">Principio del documento</option>
620
+ <option value="mitad">Mitad del documento</option>
621
+ <option value="final">Final del documento</option>
622
+ </select>
623
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
624
+ <div class="msg" id="msgGuion"></div>
625
+ </div>
626
+
627
+ <div class="fase">
628
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
629
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
630
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
631
+ <button onclick="dividirEscenas()" style="background:#1565c0;color:#fff;border:none;padding:10px;border-radius:8px;margin-top:8px;width:100%;font-weight:600">Dividir en escenas</button>
632
+ <div id="escenas" style="margin-top:12px"></div>
633
+ </div>
634
+
635
+ <div class="fase">
636
+ <div class="fase-t">3. Tu material</div>
637
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
638
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
639
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
640
+ </div>
641
+
642
+ <div class="fase">
643
+ <div class="fase-t">4. Voz</div>
644
+ <select id="tipoVoz" onchange="cambioVoz()">
645
+ <option value="ia">Voz IA (automatica)</option>
646
+ <option value="grabada">Mi voz grabada (con microfono)</option>
647
+ </select>
648
+ <div id="vozIA">
649
+ <div class="row">
650
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
651
+ <select id="pais"><option value="">Acento neutro</option><option value="españa">Espana</option><option value="mexico">Mexico</option><option value="argentina">Argentina</option><option value="colombia">Colombia</option></select>
652
+ </div>
653
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
654
+ </div>
655
+ <div id="vozGrabada" style="display:none">
656
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
657
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
658
+ </div>
659
+ <div class="msg" id="msgVoz"></div>
660
+ <audio id="player" controls style="display:none"></audio>
661
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
662
+ <span class="lbl">Musica de fondo (opcional, sonara bajita bajo la voz):</span>
663
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
664
+ <div class="msg" id="msgMusica"></div>
665
+ </div>
666
+ </div>
667
+
668
+ <div class="fase">
669
+ <div class="fase-t">5. Subtitulos y Montaje</div>
670
+ <div class="row">
671
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
672
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
673
+ </div>
674
+ <div class="row">
675
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
676
+ <select id="subColor"><option value="#FFFFFF">Blanco</option><option value="#C0C0C0">Plata</option><option value="#000000">Negro</option><option value="#FFD700">Dorado</option><option value="#FFFF00">Amarillo</option><option value="#FFA500">Naranja</option><option value="#FF0000">Rojo</option><option value="#FF4081">Rosa</option><option value="#FF00FF">Magenta</option><option value="#00E5FF">Cian</option><option value="#00BFFF">Azul cielo</option><option value="#1565C0">Azul</option><option value="#76FF03">Verde lima</option><option value="#00C853">Verde</option><option value="#9C27B0">Morado</option><option value="#FF6B00">Naranja fuego</option></select>
677
+ </div>
678
+ <span class="lbl">Efecto de video:</span>
679
+ <select id="efecto">
680
+ <option value="ninguno">Sin efecto (estatico)</option>
681
+ <option value="zoom">Zoom lento (Ken Burns)</option>
682
+ <option value="fundido">Fundido suave entre planos</option>
683
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
684
+ <option value="rafaga">Rafaga rapida (videoclip)</option>
685
+ </select>
686
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
687
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
688
+ <div class="msg" id="msgVideo"></div>
689
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
690
+ </div>
691
+
692
+ <div class="fase">
693
+ <div class="fase-t">6. Publicacion</div>
694
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
695
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">Nuevo Video (limpiar todo)</button>
696
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
697
+ <div class="msg" id="msgPub"></div>
698
+ </div>
699
+
700
+ <script>
701
+ var ARCHIVOS_SUBIDOS=[];
702
+ async function subirPdf(){
703
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
704
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
705
+ var fd=new FormData(); fd.append("file",f);
706
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
707
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
708
+ else{m.textContent=d.error;m.style.color="#ff5252";}
709
+ }
710
+ async function genGuion(){
711
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
712
+ var r=await fetch("/generar-guion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({texto:document.getElementById("texto").value,estilo:document.getElementById("estilo").value,parte:document.getElementById("parte").value,duracion:document.getElementById("duracion").value})});
713
+ var d=await r.json();
714
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
715
+ else{m.textContent=d.error;m.style.color="#ff5252";}
716
+ }
717
+ async function subirMaterial(){
718
+ var files=document.getElementById("material").files; if(!files.length)return;
719
+ var m=document.getElementById("msgMaterial");
720
+ var total=0;
721
+ for(var i=0;i<files.length;i++){
722
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
723
+ var fd=new FormData(); fd.append("file",files[i]);
724
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
725
+ if(d.ok){total=d.total; if(d.nombre){ARCHIVOS_SUBIDOS.push(d.nombre);}}
726
+ }
727
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
728
+ }
729
+ async function genVoz(){
730
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
731
+ var guionFinal=document.getElementById("guion").value;
732
+ var esc=recolectarEscenas();
733
+ if(esc.length>0){
734
+ var textos=[];
735
+ for(var k=0;k<esc.length;k++){ if(esc[k].texto){textos.push(esc[k].texto);} }
736
+ if(textos.length>0){guionFinal=textos.join(". ");}
737
+ }
738
+ var r=await fetch("/generar-voz",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({guion:guionFinal,genero:document.getElementById("genero").value,pais:document.getElementById("pais").value})});
739
+ var d=await r.json();
740
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="Voz lista, escuchala:";m.style.color="#00c853";
741
+ p.onloadedmetadata=function(){ sugerirSegundos(p.duration); };
742
+ }
743
+ else{m.textContent=d.error;m.style.color="#ff5252";}
744
+ }
745
+ function sugerirSegundos(durVoz){
746
+ if(!durVoz||durVoz<1){return;}
747
+ var items=document.getElementsByClassName("escenaItem");
748
+ if(items.length==0){return;}
749
+ // Contar palabras de cada escena y el total
750
+ var palabrasEsc=[]; var total=0;
751
+ for(var i=0;i<items.length;i++){
752
+ var t=items[i].getElementsByClassName("txtEscena")[0];
753
+ var np=t?t.value.trim().split(" ").filter(function(x){return x.length>0;}).length:1;
754
+ palabrasEsc.push(np); total+=np;
755
+ }
756
+ // Repartir la duracion de la voz segun palabras y rellenar
757
+ var sumaSeg=0;
758
+ for(var i=0;i<items.length;i++){
759
+ var seg=Math.max(1, Math.round(durVoz*(palabrasEsc[i]/total)));
760
+
761
+ sumaSeg+=seg;
762
+ var campo=items[i].getElementsByClassName("segEscena")[0];
763
+ if(campo){campo.value=seg;}
764
+ }
765
+ // Mostrar total abajo
766
+ var tot=document.getElementById("totalSeg");
767
+ if(!tot){
768
+ tot=document.createElement("div");
769
+ tot.id="totalSeg";
770
+ tot.style.cssText="background:#1565c0;color:#fff;padding:10px;border-radius:8px;margin-top:10px;font-weight:700;text-align:center";
771
+ var cont=document.getElementById("escenas");
772
+ if(cont){cont.appendChild(tot);}
773
+ }
774
+ tot.textContent="Total del video: "+sumaSeg+" segundos (voz: "+Math.round(durVoz)+"s). Ajusta si quieres.";
775
+ }
776
+ var pollTimer=null;
777
+ function recolectarEscenas(){
778
+ var items=document.getElementsByClassName("escenaItem");
779
+ var lista=[];
780
+ for(var i=0;i<items.length;i++){
781
+ var sel=items[i].getElementsByClassName("selMaterial")[0];
782
+ var mat=sel?sel.value:"";
783
+ var seg=items[i].getElementsByClassName("segEscena")[0];
784
+ var segs=seg?parseInt(seg.value):5;
785
+ if(!segs||segs<1){segs=5;}
786
+ var txt=items[i].getElementsByClassName("txtEscena")[0];
787
+ var texto=txt?txt.value:"";
788
+ lista.push({material:mat,segundos:segs,texto:texto});
789
+ }
790
+ return lista;
791
+ }
792
+ async function genVideo(){
793
+ var m=document.getElementById("msgVideo");
794
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
795
+ // Lanzar el render sin esperar la respuesta (evita timeout)
796
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
797
+ guion:document.getElementById("guion").value,
798
+ sub_activo:document.getElementById("subActivo").value==="si",
799
+ sub_tamano:document.getElementById("subTamano").value,
800
+ sub_posicion:document.getElementById("subPosicion").value,
801
+ sub_color:document.getElementById("subColor").value,
802
+ efecto:document.getElementById("efecto").value,
803
+ escenas:recolectarEscenas()
804
+ })}).catch(e=>{});
805
+ // Comprobar cada 4 segundos si el video esta listo
806
+ var inicio=Date.now();
807
+ if(pollTimer)clearInterval(pollTimer);
808
+ pollTimer=setInterval(async()=>{
809
+ try{
810
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
811
+ if(d.listo && d.mtime*1000 > inicio){
812
+ clearInterval(pollTimer);
813
+ var u="/video?t="+Date.now();
814
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
815
+ m.innerHTML="<span class=\'tick\'>✓</span> Video listo! <a href=\'"+u+"\' target=\'_blank\' style=\'display:inline-block;background:#00E5FF;color:#000;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:800;margin-top:8px\'>▶ VER VIDEO</a>";
816
+ m.style.color="#00c853";
817
+ }
818
+ }catch(e){}
819
+ },4000);
820
+ }
821
+ async function subirMusica(){
822
+ var f=document.getElementById("musica").files[0]; if(!f)return;
823
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
824
+ var fd=new FormData(); fd.append("file",f);
825
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
826
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
827
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
828
+ }
829
+ function cargarVideo(){
830
+ var u="/video?t="+Date.now();
831
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
832
+ var m=document.getElementById("msgVideo");
833
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
834
+ m.style.color="#00c853";
835
+ }
836
+ async function nuevoVideo(){
837
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
838
+ try{
839
+ await fetch("/reset",{method:"POST"});
840
+ location.reload();
841
+ }catch(e){alert("Error al limpiar");}
842
+ }
843
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
844
+ function dividirEscenas(){
845
+ var t=document.getElementById("guion").value;
846
+ var sep=String.fromCharCode(10)+String.fromCharCode(10);
847
+ var bloques=t.split(sep);
848
+ var cont=document.getElementById("escenas");
849
+ var html="";
850
+ var n=0;
851
+ for(var i=0;i<bloques.length;i++){
852
+ var b=bloques[i].trim();
853
+ if(b==""){continue;}
854
+ n++;
855
+ var opciones="<option value=>-- material automatico --</option>";
856
+ for(var j=0;j<ARCHIVOS_SUBIDOS.length;j++){ opciones+="<option value="+ARCHIVOS_SUBIDOS[j]+">"+ARCHIVOS_SUBIDOS[j]+"</option>"; }
857
+ html+="<div class=escenaItem><b>Escena "+n+"</b><br><textarea class=txtEscena style=width:100%;min-height:50px;background:#111;color:#fff;border:1px solid #333;border-radius:6px;padding:6px>"+b+"</textarea><br><select class=selMaterial>"+opciones+"</select> <input type=number class=segEscena min=1 value=5 style=width:70px> seg</div>";
858
+ }
859
+ if(n==0){html="<div style=color:#888>Separa los parrafos con una linea en blanco</div>";}
860
+ cont.innerHTML=html;
861
+ }
862
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
863
+
864
+ function cambioVoz(){
865
+ var t=document.getElementById("tipoVoz").value;
866
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
867
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
868
+ }
869
+ var mediaRec=null, chunks=[], grabando=false;
870
+ async function toggleRec(){
871
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
872
+ if(!grabando){
873
+ try{
874
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
875
+ mediaRec=new MediaRecorder(stream); chunks=[];
876
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
877
+ mediaRec.onstop=async()=>{
878
+ var blob=new Blob(chunks,{type:"audio/webm"});
879
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
880
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
881
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
882
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="✅ Tu voz lista, escuchala:";m.style.color="#00c853";}
883
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
884
+ };
885
+ mediaRec.start(); grabando=true;
886
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
887
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
888
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
889
+ }else{
890
+ mediaRec.stop(); grabando=false;
891
+ btn.textContent="Empezar a grabar"; btn.style.background="";
892
+ }
893
+ }
894
+ </script>
895
+ </body></html>"""
app_backup_antes_ltx_1144.py ADDED
@@ -0,0 +1,915 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, glob, random, asyncio, base64
2
+ from fastapi import FastAPI, Request, UploadFile, File
3
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
4
+ from groq import Groq
5
+ import edge_tts, requests
6
+
7
+ # Servicio Veo independiente (con cascada a Leonardo.ai)
8
+ try:
9
+ from veo_service import generar_clips_escenas as generar_runway
10
+ except Exception:
11
+ def generar_runway(escenas): return None
12
+ try:
13
+ from leonardo_service import generar_clips_escenas as generar_leonardo
14
+ except Exception:
15
+ def generar_leonardo(escenas): return None
16
+ def generar_clips_escenas(escenas):
17
+ import copy
18
+ res = generar_runway(copy.deepcopy(escenas))
19
+ if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
20
+ print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
21
+ res_leo = generar_leonardo(copy.deepcopy(escenas))
22
+ return res_leo if res_leo else escenas
23
+
24
+ app = FastAPI()
25
+
26
+ 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]
27
+
28
+ CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026")
29
+
30
+ def verificar_clave(clave):
31
+ return clave == CLAVE_ACCESO
32
+
33
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
34
+
35
+ # Estado de la sesion (guarda guion editado y material entre pasos)
36
+ ESTADO = {
37
+ "guion": "",
38
+ "archivos": [],
39
+ "voz_lista": False,
40
+ "video_listo": False,
41
+ }
42
+
43
+ ESTILOS = {
44
+ "narrativo": "narrativo y emocional, como contando una historia",
45
+ "educativo": "educativo y claro, explicando con autoridad",
46
+ "motivador": "motivador e inspirador, que mueva a la accion",
47
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
48
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
49
+ }
50
+
51
+ VOCES = {
52
+ "es": {
53
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
54
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
55
+ },
56
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
57
+ }
58
+
59
+ def log(m): print(m, flush=True)
60
+
61
+ # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua.
62
+ # Si falla, devuelve el clip original (nunca rompe el montaje).
63
+ def quitar_marca_meta(path, _cache={}):
64
+ exts_v = (".mp4",".mov",".webm",".avi",".mkv")
65
+ exts_i = (".jpg",".jpeg",".png",".webp")
66
+ low = str(path).lower()
67
+ if not low.endswith(exts_v + exts_i):
68
+ return path
69
+ if path in _cache:
70
+ return _cache[path]
71
+ try:
72
+ import subprocess
73
+ dims = subprocess.run(
74
+ ["ffprobe","-v","error","-select_streams","v:0",
75
+ "-show_entries","stream=width,height","-of","csv=p=0:s=x", path],
76
+ capture_output=True, text=True).stdout.strip()
77
+ W, H = [int(v) for v in dims.split("x")[:2]]
78
+ # Tapar marca "Veo" (banda negra abajo-derecha) con "Teshua" dorado
79
+ es_video = low.endswith(exts_v)
80
+ salida = path + ("_clean.mp4" if es_video else "_clean.png")
81
+ fs = max(22, int(H * 0.030))
82
+ pad = int(fs * 0.5)
83
+ # Posicion: pegado abajo-derecha, dentro de la banda negra donde aparece "Veo"
84
+ tx = W - int(W * 0.02) # x: desde la derecha
85
+ ty = H - int(H * 0.018) # y: casi en el borde inferior
86
+ # 1) Caja negra que tapa "Veo" completamente
87
+ # 2) Borde dorado alrededor
88
+ # 3) Texto "Teshua" dorado encima
89
+ vf = (
90
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=black@1:t=fill,"
91
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=0xFFD700@1:t=2,"
92
+ f"drawtext=text='Teshuá':fontsize={fs}:fontcolor=0xFFD700:x=W-tw-{int(W*0.03)}:y=H-th-{int(H*0.012)}:font=serif:borderw=2:bordercolor=black"
93
+ )
94
+ cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf]
95
+ cmd += (["-c:a","copy",salida] if es_video else [salida])
96
+ result = subprocess.run(cmd, capture_output=True, text=True)
97
+ if result.returncode != 0:
98
+ log(f"marca ffmpeg error: {result.stderr[:300]}")
99
+ if os.path.exists(salida) and os.path.getsize(salida) > 0:
100
+ log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if os.path.exists('logo.png') else 'no'})")
101
+ _cache[path] = salida; return salida
102
+ except Exception as e:
103
+ log(f"marca error: {e}")
104
+ _cache[path] = path
105
+ return path
106
+
107
+ # ========== GUION ==========
108
+ def generar_guion(texto_base, estilo, duracion=60):
109
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
110
+ palabras = int(duracion * 2.4)
111
+ last_err = None
112
+ MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"]
113
+ 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."
114
+ # Probar cada modelo con cada key hasta que uno funcione
115
+ for modelo in MODELOS:
116
+ for k in GROQ_KEYS:
117
+ try:
118
+ client = Groq(api_key=k)
119
+ res = client.chat.completions.create(
120
+ messages=[{"role":"user","content":prompt}],
121
+ model=modelo, temperature=0.85
122
+ )
123
+ return res.choices[0].message.content.strip()
124
+ except Exception as e:
125
+ last_err = e
126
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
127
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
128
+ continue
129
+ raise last_err if last_err else Exception("Sin keys Groq")
130
+
131
+ # ========== VOZ ==========
132
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
133
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
134
+ if pais:
135
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
136
+ cod = cods.get(pais.lower(),"")
137
+ if cod:
138
+ filt = [v for v in voces if f"-{cod}-" in v]
139
+ if filt: voces = filt
140
+ voz_sel = random.choice(voces)
141
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
142
+ return voz_sel
143
+
144
+ # ========== VIDEO ==========
145
+ def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None):
146
+ subs = subs or {}
147
+ material_escenas = material_escenas or []
148
+ escenas_data = escenas_data or []
149
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
150
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
151
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
152
+
153
+ voz = AC("voz.mp3")
154
+ # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales
155
+ if material_escenas:
156
+ fondos = [m for m in material_escenas if os.path.exists(m)]
157
+ else:
158
+ fondos = []
159
+ # Respaldo: si no hay fondos de escenas validos, usar el material general
160
+ if not fondos:
161
+ fondos = archivos[:8] if archivos else []
162
+
163
+ # Si no hay material propio, fondo de Pexels
164
+ raise Exception("[Fenix Error] Leonardo AI fallo o no tiene saldo, y Pexels esta desactivado.")
165
+
166
+ # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar ===
167
+ fondos = [quitar_marca_meta(f) for f in fondos]
168
+ if material_escenas:
169
+ material_escenas = [quitar_marca_meta(m) for m in material_escenas]
170
+ if escenas_data:
171
+ escenas_data = [(quitar_marca_meta(m) if m else m, s) for m, s in escenas_data]
172
+
173
+ # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz
174
+ if escenas_data:
175
+ suma_seg = sum(s for m,s in escenas_data)
176
+ dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200)
177
+ else:
178
+ dur_total = min(voz.duration, 1200)
179
+
180
+ # Si el material son SOLO videos, usar el video entero de fondo (no trocear)
181
+ solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos)
182
+ if solo_videos:
183
+ from moviepy.editor import concatenate_videoclips as _ccv
184
+ vclips = []
185
+ for fp in fondos:
186
+ try:
187
+ vc = VC(fp).without_audio()
188
+ vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080)
189
+ vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920)
190
+ vclips.append(vc)
191
+ except Exception as e:
192
+ log(f"Error video {fp}: {e}")
193
+ if vclips:
194
+ base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0]
195
+ # Repetir el video en bucle hasta cubrir toda la duracion (sin negro)
196
+ if base.duration < dur_total:
197
+ from moviepy.editor import concatenate_videoclips as _ccloop
198
+ repes = []
199
+ acumulado = 0
200
+ while acumulado < dur_total:
201
+ repes.append(base)
202
+ acumulado += base.duration
203
+ base = _ccloop(repes, method="compose").subclip(0, dur_total)
204
+ else:
205
+ base = base.subclip(0, dur_total)
206
+ clip = base
207
+ voz = voz.subclip(0, min(dur_total, voz.duration))
208
+ _USAR_VIDEO_ENTERO = True
209
+ else:
210
+ _USAR_VIDEO_ENTERO = False
211
+ else:
212
+ _USAR_VIDEO_ENTERO = False
213
+
214
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
215
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
216
+ dur_clip = dur_total / num_planos
217
+ # Si hay escenas con segundos, cada clip dura SUS segundos
218
+ # Solo usar segundos por escena si hay material asignado en las escenas
219
+ material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)]
220
+ usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO
221
+ if usar_seg_escena:
222
+ fondos_loop = [m for m,s in material_en_escenas]
223
+ segundos_loop = [s for m,s in material_en_escenas]
224
+ clips = []
225
+ idx_clip = -1
226
+ # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz
227
+ _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO
228
+ if _es_rafaga and fondos_loop:
229
+ dur_rafaga = 0.2
230
+ n_necesarias = int(dur_total / dur_rafaga) + 1
231
+ fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)]
232
+ segundos_loop = [dur_rafaga for _ in fondos_loop]
233
+ usar_seg_escena = True
234
+ for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []):
235
+ idx_clip += 1
236
+ d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip
237
+ try:
238
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
239
+ img = PILImage.open(fp).convert("RGB")
240
+ w, h = img.size
241
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
242
+ ratio = min(1080/w, 1920/h)
243
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
244
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
245
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
246
+ ic = IC(fp+"_r.jpg").set_duration(d_este)
247
+ if efecto in ("zoom","zoom_fundido"):
248
+ ic = ic.resize(lambda t: 1 + 0.04*t).set_duration(d_este)
249
+ ic = ic.set_duration(d_este)
250
+ clips.append(ic)
251
+ else:
252
+ vclip = VC(fp).without_audio()
253
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
254
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
255
+ vclip = vclip.loop(duration=d_este).set_duration(d_este)
256
+ clips.append(vclip)
257
+ except Exception as e:
258
+ log(f"Error clip {fp}: {e}")
259
+
260
+ if not _USAR_VIDEO_ENTERO:
261
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
262
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
263
+ from moviepy.editor import concatenate_videoclips as _cc
264
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
265
+ clip = _cc(clips_fx, method="compose", padding=-0.5)
266
+ else:
267
+ clip = concatenate_videoclips(clips, method="compose")
268
+ # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta
269
+ _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False
270
+ if _es_manual:
271
+ suma = sum(s for m,s in escenas_data if m and os.path.exists(m))
272
+ if clip.duration < suma:
273
+ # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro)
274
+ from moviepy.editor import ImageClip as _ICf
275
+ ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration)
276
+ from moviepy.editor import concatenate_videoclips as _ccf
277
+ clip = _ccf([clip, ultimo.set_pos("center")], method="compose")
278
+ else:
279
+ clip = clip.subclip(0, suma)
280
+ dur_total = suma
281
+ else:
282
+ # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar)
283
+ dur_total = clip.duration
284
+ voz = voz.subclip(0, min(dur_total, voz.duration))
285
+
286
+ import re as _re
287
+ sub_color = subs.get("color","#FFFFFF")
288
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
289
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
290
+ sub_activo = subs.get("activo",True)
291
+
292
+ # --- SUBTITULOS SINCRONIZADOS POR FRASE ---
293
+ # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea
294
+ crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()]
295
+ # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas
296
+ segmentos = []
297
+ for fr in crudo:
298
+ pal = fr.split()
299
+ if len(pal) <= 8:
300
+ segmentos.append(fr)
301
+ else:
302
+ for i in range(0, len(pal), 7):
303
+ segmentos.append(" ".join(pal[i:i+7]))
304
+ if not segmentos:
305
+ segmentos = [guion]
306
+ # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia)
307
+ total_pal = sum(max(len(s.split()), 1) for s in segmentos)
308
+ dur_voz = voz.duration
309
+ txts = []
310
+ if sub_activo:
311
+ t_cursor = 0.0
312
+ for s in segmentos:
313
+ d = dur_voz * (max(len(s.split()), 1) / total_pal)
314
+ txts.append(
315
+ TextClip(s.upper(), fontsize=sub_size, color=sub_color,
316
+ font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
317
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
318
+ ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True)
319
+ )
320
+ t_cursor += d
321
+
322
+ # Mezclar musica de fondo si el cliente la subio
323
+ import os as _os
324
+ audio_final = voz
325
+ if _os.path.exists("musica.mp3"):
326
+ try:
327
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
328
+ musica = _AC("musica.mp3").volumex(0.18) # musica bajita para no tapar la voz
329
+ if musica.duration > voz.duration:
330
+ musica = musica.subclip(0, voz.duration)
331
+ else:
332
+ musica = musica.audio_loop(duration=voz.duration)
333
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
334
+ except Exception as _em:
335
+ log(f"Error mezcla musica: {_em}")
336
+ audio_final = voz
337
+ final = CompositeVideoClip([clip]+txts).set_audio(audio_final)
338
+ import os as _os2
339
+ if _os2.path.exists("preview.mp4"):
340
+ _os2.remove("preview.mp4")
341
+ final.write_videofile("preview_tmp.mp4", fps=20, codec="libx264", preset="ultrafast", logger=None, threads=4, audio_codec="aac")
342
+ # Renombrar solo cuando esta 100% terminado (evita cargar video a medias)
343
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
344
+ return "preview.mp4"
345
+
346
+ # ========== ENDPOINTS ==========
347
+ @app.post("/login")
348
+ async def ep_login(request: Request):
349
+ d = await request.json()
350
+ clave = d.get("clave","").strip()
351
+ if verificar_clave(clave):
352
+ return {"ok": True}
353
+ return JSONResponse({"error":"Clave incorrecta"}, status_code=401)
354
+
355
+ @app.post("/generar-guion")
356
+ async def ep_guion(request: Request):
357
+ d = await request.json()
358
+ texto = d.get("texto","").strip()
359
+ estilo = d.get("estilo","narrativo")
360
+ duracion = int(d.get("duracion", 60) or 60)
361
+ parte = d.get("parte","auto")
362
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
363
+ completo = ESTADO.get("texto_completo","")
364
+ if completo and len(completo) > 6000:
365
+ if parte == "principio":
366
+ texto = completo[:6000]
367
+ elif parte == "mitad":
368
+ m = len(completo)//2
369
+ texto = completo[m-3000:m+3000]
370
+ elif parte == "final":
371
+ texto = completo[-6000:]
372
+ else: # auto = trozo al azar
373
+ import random as _r
374
+ ini = _r.randint(0, max(0, len(completo)-6000))
375
+ texto = completo[ini:ini+6000]
376
+ if not texto:
377
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
378
+ try:
379
+ guion = generar_guion(texto, estilo, duracion)
380
+ ESTADO["guion"] = guion
381
+ return {"guion": guion}
382
+ except Exception as e:
383
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
384
+
385
+ @app.post("/subir-material")
386
+ async def ep_material(file: UploadFile = File(...)):
387
+ try:
388
+ import subprocess as _sp, os as _os
389
+ ext = file.filename.split(".")[-1].lower()
390
+ idx = len(ESTADO['archivos'])
391
+ if ext in ("mp4","mov","webm","avi","mkv","m4v"):
392
+ crudo = f"crudo_{idx}.{ext}"
393
+ with open(crudo,"wb") as f:
394
+ f.write(await file.read())
395
+ nombre = f"media_{idx}.mp4"
396
+ # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros)
397
+ r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True)
398
+ if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000:
399
+ try: _os.remove(crudo)
400
+ except: pass
401
+ else:
402
+ nombre = crudo
403
+ else:
404
+ nombre = f"media_{idx}.{ext}"
405
+ with open(nombre,"wb") as f:
406
+ f.write(await file.read())
407
+ ESTADO["archivos"].append(nombre)
408
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
409
+ except Exception as e:
410
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
411
+
412
+ @app.post("/generar-voz")
413
+ async def ep_voz(request: Request):
414
+ d = await request.json()
415
+ guion = d.get("guion","").strip()
416
+ if not guion:
417
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
418
+ ESTADO["guion"] = guion
419
+ try:
420
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
421
+ ESTADO["voz_lista"] = True
422
+ return {"ok": True}
423
+ except Exception as e:
424
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
425
+
426
+
427
+ @app.post("/subir-voz-grabada")
428
+ async def ep_voz_grabada(file: UploadFile = File(...)):
429
+ try:
430
+ import subprocess
431
+ contenido = await file.read()
432
+ with open("voz_grabada_raw","wb") as f:
433
+ f.write(contenido)
434
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
435
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
436
+ import os as _os
437
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
438
+ ESTADO["voz_lista"] = True
439
+ return {"ok": True}
440
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
441
+ except Exception as e:
442
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
443
+
444
+
445
+ @app.post("/subir-musica")
446
+ async def ep_musica(file: UploadFile = File(...)):
447
+ try:
448
+ import subprocess, os as _os
449
+ contenido = await file.read()
450
+ with open("musica_raw","wb") as f:
451
+ f.write(contenido)
452
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
453
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
454
+ ESTADO["musica"] = True
455
+ return {"ok": True}
456
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
457
+ except Exception as e:
458
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
459
+
460
+ @app.get("/audio")
461
+ def ep_audio():
462
+ if os.path.exists("voz.mp3"):
463
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
464
+ return JSONResponse({"error":"sin audio"}, status_code=404)
465
+
466
+ @app.post("/generar-video")
467
+ async def ep_video(request: Request):
468
+ d = await request.json()
469
+ guion = d.get("guion","").strip() or ESTADO["guion"]
470
+ if not os.path.exists("voz.mp3"):
471
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
472
+ subs = {
473
+ "color": d.get("sub_color","#FFFFFF"),
474
+ "tamano": d.get("sub_tamano","mediano"),
475
+ "posicion": d.get("sub_posicion","abajo"),
476
+ "activo": d.get("sub_activo", True),
477
+ }
478
+ efecto = d.get("efecto","ninguno")
479
+ escenas = d.get("escenas", [])
480
+ # Modo híbrido: rellenar escenas vacías con clips de Veo
481
+ if ESTADO["archivos"]:
482
+ # Si hay archivos subidos, los asignamos a las escenas automáticas
483
+ for idx, e in enumerate(escenas):
484
+ mat = str(e.get("material", "")).strip()
485
+ if not mat or mat == "automático" or mat == "automatico":
486
+ # Usamos tus archivos de forma circular si hay menos que escenas
487
+ e["material"] = ESTADO["archivos"][idx % len(ESTADO["archivos"])]
488
+ if not ESTADO["archivos"]:
489
+ escenas = generar_clips_escenas(escenas)
490
+ # Lista de (material, segundos) de cada escena, en orden
491
+ escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas]
492
+ material_escenas = [m for m,s in escenas_data if m]
493
+ try:
494
+ loop = asyncio.get_event_loop()
495
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data)
496
+ ESTADO["video_listo"] = True
497
+ return {"ok": True}
498
+ except Exception as e:
499
+ import traceback
500
+ tb = traceback.format_exc()
501
+ print("ERROR VIDEO:", tb, flush=True)
502
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
503
+
504
+
505
+ @app.get("/video-listo")
506
+ def ep_video_listo():
507
+ import os as _os
508
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
509
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
510
+ return {"listo": False}
511
+
512
+ @app.get("/video")
513
+ def ep_video_file():
514
+ if os.path.exists("preview.mp4"):
515
+ return FileResponse("preview.mp4", media_type="video/mp4")
516
+ return JSONResponse({"error":"sin video"}, status_code=404)
517
+
518
+ @app.post("/reset")
519
+ def ep_reset():
520
+ for f in glob.glob("media_*")+glob.glob("fondo*")+glob.glob("leonardo_*")+["voz.mp3","preview.mp4","musica.mp3","musica_raw"]:
521
+ try: os.remove(f)
522
+ except: pass
523
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
524
+ return {"ok": True}
525
+
526
+ @app.post("/subir-pdf")
527
+ async def ep_pdf(file: UploadFile = File(...)):
528
+ try:
529
+ contenido = await file.read()
530
+ with open("temp.pdf","wb") as f:
531
+ f.write(contenido)
532
+ from pypdf import PdfReader
533
+ reader = PdfReader("temp.pdf")
534
+ texto = ""
535
+ for pagina in reader.pages:
536
+ texto += (pagina.extract_text() or "") + "\n"
537
+ texto = texto.strip()
538
+ if not texto:
539
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
540
+ ESTADO["texto_completo"] = texto
541
+ # Mostrar preview de los primeros 6000 al usuario
542
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
543
+ except Exception as e:
544
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
545
+
546
+ # ========== INTERFAZ ==========
547
+ @app.get("/", response_class=HTMLResponse)
548
+ def home():
549
+ return """<!DOCTYPE html>
550
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
551
+ <title>Fenix Hybrid Engine</title>
552
+ <style>
553
+ *{box-sizing:border-box;margin:0;padding:0}
554
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
555
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
556
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
557
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
558
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
559
+ textarea,select,input{width:100%;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:8px;padding:12px;font-size:.95rem;font-family:inherit;margin-bottom:10px}
560
+ textarea{min-height:90px;resize:vertical}
561
+ button{background:linear-gradient(90deg,#ff6b00,#ffb700);color:#000;border:none;padding:14px 20px;border-radius:8px;font-weight:800;font-size:1rem;cursor:pointer;width:100%;margin-top:6px}
562
+ button:disabled{opacity:.4}
563
+ .btn2{background:#222;color:#fff;border:1px solid #444}
564
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
565
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
566
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
567
+ .row{display:flex;gap:8px}.row>*{flex:1}
568
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
569
+
570
+ .spinner{display:inline-block;width:24px;height:24px;border:3px solid #333;border-top:3px solid #ffb700;border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle;margin-right:8px}
571
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
572
+ .tick{display:inline-block;width:24px;height:24px;background:#00c853;border-radius:50%;color:#fff;text-align:center;line-height:24px;font-weight:900;margin-right:8px;vertical-align:middle}
573
+ </style></head><body>
574
+ <div id="loginOverlay" style="position:fixed;inset:0;background:#0a0a0a;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px">
575
+ <div style="font-size:2rem;font-weight:800;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px">Fenix Hybrid Engine</div>
576
+ <p style="color:#888;margin-bottom:24px;font-size:.95rem">Introduce tu clave de acceso</p>
577
+ <input type="password" id="claveInput" placeholder="Clave de acceso" style="width:100%;max-width:300px;background:#111;color:#fff;border:1px solid #333;border-radius:10px;padding:14px;font-size:1rem;margin-bottom:12px" onkeydown="if(event.key==='Enter')hacerLogin()">
578
+ <button onclick="hacerLogin()" style="width:100%;max-width:300px;background:linear-gradient(135deg,#ff6b00,#ff3d00);color:#fff;border:none;padding:14px;border-radius:10px;font-weight:800;font-size:1rem;cursor:pointer">Entrar</button>
579
+ <div id="loginMsg" style="color:#ff5252;margin-top:12px;font-size:.9rem;min-height:18px"></div>
580
+ </div>
581
+ <script>
582
+ async function hacerLogin(){
583
+ var clave=document.getElementById("claveInput").value;
584
+ var msg=document.getElementById("loginMsg");
585
+ msg.textContent="Comprobando...";msg.style.color="#ffb700";
586
+ try{
587
+ var r=await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clave:clave})});
588
+ var d=await r.json();
589
+ if(d.ok){document.getElementById("loginOverlay").style.display="none";}
590
+ else{msg.textContent="Clave incorrecta";msg.style.color="#ff5252";}
591
+ }catch(e){msg.textContent="Error";msg.style.color="#ff5252";}
592
+ }
593
+ </script>
594
+
595
+ <h1>Fenix Hybrid Engine</h1>
596
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
597
+ <button onclick="var p=document.getElementById('panelAyuda');p.style.display=(p.style.display=='none'||!p.style.display)?'block':'none';" style="background:#ff6b00;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-weight:700;cursor:pointer;margin:10px 0">Como usar (pulsa aqui)</button>
598
+ <div id="panelAyuda" style="display:none;background:#1a1a1a;border:1px solid #ff6b00;border-radius:10px;padding:16px;margin:10px 0;line-height:1.6">
599
+ <b>COMO HACER TU VIDEO (paso a paso):</b><br><br>
600
+ <b>1.</b> Sube un PDF o pega tu texto.<br>
601
+ <b>2.</b> Elige la duracion (15s, 30s, 45s, 1 o 2 min) y el estilo. Pulsa Generar Guion.<br>
602
+ <b>3.</b> Si quieres, edita el guion a tu gusto.<br>
603
+ <b>4.</b> Pulsa Dividir en escenas.<br>
604
+ <b>5.</b> Sube tus imagenes o videos.<br>
605
+ <b>6.</b> Genera la voz. Los segundos de cada escena se ponen SOLOS segun la voz, y abajo veras el total.<br>
606
+ <b>7.</b> Elige color de subtitulos y efecto si quieres.<br>
607
+ <b>8.</b> Pulsa Generar Video y descargalo. Sale perfecto, sin pantalla negra.<br><br>
608
+ <b>DOS MODOS DE MONTAR:</b><br>
609
+ - <b>AUTOMATICO (facil):</b> deja el material en automatico. El sistema reparte las imagenes solo. Ideal para ir rapido.<br>
610
+ - <b>MANUAL (control total):</b> asigna una imagen a cada escena. Tu decides que imagen va en cada parte.<br><br>
611
+ <b>CONSEJO IMPORTANTE:</b> los segundos salen solos al generar la voz, no tienes que calcular nada. Si quieres que una escena dure mas o menos, NO toques los segundos: edita su TEXTO (hazlo mas largo o mas corto) y vuelve a generar la voz. Los segundos se ajustan solos. Asi es mas facil y siempre cuadra.<br>
612
+ </div>
613
+
614
+ <div class="fase">
615
+ <div class="fase-t">1. Ingesta de conocimiento</div>
616
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
617
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
618
+ <div class="msg" id="msgPdf"></div>
619
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
620
+ <span class="lbl">Duracion del video:</span>
621
+ <select id="duracion">
622
+ <option value="15">15 segundos</option>
623
+ <option value="30">30 segundos</option>
624
+ <option value="45">45 segundos</option>
625
+ <option value="60">1 minuto</option>
626
+ <option value="120">2 minutos</option>
627
+ </select>
628
+ <span class="lbl">Estilo de narrativa:</span>
629
+ <select id="estilo">
630
+ <option value="narrativo">Narrativo y emocional</option>
631
+ <option value="educativo">Educativo y claro</option>
632
+ <option value="motivador">Motivador e inspirador</option>
633
+ <option value="publicitario">Publicitario y persuasivo</option>
634
+ <option value="misterio">Misterio e intriga</option>
635
+ </select>
636
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
637
+ <select id="parte">
638
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
639
+ <option value="principio">Principio del documento</option>
640
+ <option value="mitad">Mitad del documento</option>
641
+ <option value="final">Final del documento</option>
642
+ </select>
643
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
644
+ <div class="msg" id="msgGuion"></div>
645
+ </div>
646
+
647
+ <div class="fase">
648
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
649
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
650
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
651
+ <button onclick="dividirEscenas()" style="background:#1565c0;color:#fff;border:none;padding:10px;border-radius:8px;margin-top:8px;width:100%;font-weight:600">Dividir en escenas</button>
652
+ <div id="escenas" style="margin-top:12px"></div>
653
+ </div>
654
+
655
+ <div class="fase">
656
+ <div class="fase-t">3. Tu material</div>
657
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
658
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
659
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
660
+ </div>
661
+
662
+ <div class="fase">
663
+ <div class="fase-t">4. Voz</div>
664
+ <select id="tipoVoz" onchange="cambioVoz()">
665
+ <option value="ia">Voz IA (automatica)</option>
666
+ <option value="grabada">Mi voz grabada (con microfono)</option>
667
+ </select>
668
+ <div id="vozIA">
669
+ <div class="row">
670
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
671
+ <select id="pais"><option value="">Acento neutro</option><option value="españa">Espana</option><option value="mexico">Mexico</option><option value="argentina">Argentina</option><option value="colombia">Colombia</option></select>
672
+ </div>
673
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
674
+ </div>
675
+ <div id="vozGrabada" style="display:none">
676
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
677
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
678
+ </div>
679
+ <div class="msg" id="msgVoz"></div>
680
+ <audio id="player" controls style="display:none"></audio>
681
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
682
+ <span class="lbl">Musica de fondo (opcional, sonara bajita bajo la voz):</span>
683
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
684
+ <div class="msg" id="msgMusica"></div>
685
+ </div>
686
+ </div>
687
+
688
+ <div class="fase">
689
+ <div class="fase-t">5. Subtitulos y Montaje</div>
690
+ <div class="row">
691
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
692
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
693
+ </div>
694
+ <div class="row">
695
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
696
+ <select id="subColor"><option value="#FFFFFF">Blanco</option><option value="#C0C0C0">Plata</option><option value="#000000">Negro</option><option value="#FFD700">Dorado</option><option value="#FFFF00">Amarillo</option><option value="#FFA500">Naranja</option><option value="#FF0000">Rojo</option><option value="#FF4081">Rosa</option><option value="#FF00FF">Magenta</option><option value="#00E5FF">Cian</option><option value="#00BFFF">Azul cielo</option><option value="#1565C0">Azul</option><option value="#76FF03">Verde lima</option><option value="#00C853">Verde</option><option value="#9C27B0">Morado</option><option value="#FF6B00">Naranja fuego</option></select>
697
+ </div>
698
+ <span class="lbl">Efecto de video:</span>
699
+ <select id="efecto">
700
+ <option value="ninguno">Sin efecto (estatico)</option>
701
+ <option value="zoom">Zoom lento (Ken Burns)</option>
702
+ <option value="fundido">Fundido suave entre planos</option>
703
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
704
+ <option value="rafaga">Rafaga rapida (videoclip)</option>
705
+ </select>
706
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
707
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
708
+ <div class="msg" id="msgVideo"></div>
709
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
710
+ </div>
711
+
712
+ <div class="fase">
713
+ <div class="fase-t">6. Publicacion</div>
714
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
715
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">Nuevo Video (limpiar todo)</button>
716
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
717
+ <div class="msg" id="msgPub"></div>
718
+ </div>
719
+
720
+ <script>
721
+ var ARCHIVOS_SUBIDOS=[];
722
+ async function subirPdf(){
723
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
724
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
725
+ var fd=new FormData(); fd.append("file",f);
726
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
727
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
728
+ else{m.textContent=d.error;m.style.color="#ff5252";}
729
+ }
730
+ async function genGuion(){
731
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
732
+ var r=await fetch("/generar-guion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({texto:document.getElementById("texto").value,estilo:document.getElementById("estilo").value,parte:document.getElementById("parte").value,duracion:document.getElementById("duracion").value})});
733
+ var d=await r.json();
734
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
735
+ else{m.textContent=d.error;m.style.color="#ff5252";}
736
+ }
737
+ async function subirMaterial(){
738
+ var files=document.getElementById("material").files; if(!files.length)return;
739
+ var m=document.getElementById("msgMaterial");
740
+ var total=0;
741
+ for(var i=0;i<files.length;i++){
742
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
743
+ var fd=new FormData(); fd.append("file",files[i]);
744
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
745
+ if(d.ok){total=d.total; if(d.nombre){ARCHIVOS_SUBIDOS.push(d.nombre);}}
746
+ }
747
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
748
+ }
749
+ async function genVoz(){
750
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
751
+ var guionFinal=document.getElementById("guion").value;
752
+ var esc=recolectarEscenas();
753
+ if(esc.length>0){
754
+ var textos=[];
755
+ for(var k=0;k<esc.length;k++){ if(esc[k].texto){textos.push(esc[k].texto);} }
756
+ if(textos.length>0){guionFinal=textos.join(". ");}
757
+ }
758
+ var r=await fetch("/generar-voz",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({guion:guionFinal,genero:document.getElementById("genero").value,pais:document.getElementById("pais").value})});
759
+ var d=await r.json();
760
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="Voz lista, escuchala:";m.style.color="#00c853";
761
+ p.onloadedmetadata=function(){ sugerirSegundos(p.duration); };
762
+ }
763
+ else{m.textContent=d.error;m.style.color="#ff5252";}
764
+ }
765
+ function sugerirSegundos(durVoz){
766
+ if(!durVoz||durVoz<1){return;}
767
+ var items=document.getElementsByClassName("escenaItem");
768
+ if(items.length==0){return;}
769
+ // Contar palabras de cada escena y el total
770
+ var palabrasEsc=[]; var total=0;
771
+ for(var i=0;i<items.length;i++){
772
+ var t=items[i].getElementsByClassName("txtEscena")[0];
773
+ var np=t?t.value.trim().split(" ").filter(function(x){return x.length>0;}).length:1;
774
+ palabrasEsc.push(np); total+=np;
775
+ }
776
+ // Repartir la duracion de la voz segun palabras y rellenar
777
+ var sumaSeg=0;
778
+ for(var i=0;i<items.length;i++){
779
+ var seg=Math.max(1, Math.round(durVoz*(palabrasEsc[i]/total)));
780
+
781
+ sumaSeg+=seg;
782
+ var campo=items[i].getElementsByClassName("segEscena")[0];
783
+ if(campo){campo.value=seg;}
784
+ }
785
+ // Mostrar total abajo
786
+ var tot=document.getElementById("totalSeg");
787
+ if(!tot){
788
+ tot=document.createElement("div");
789
+ tot.id="totalSeg";
790
+ tot.style.cssText="background:#1565c0;color:#fff;padding:10px;border-radius:8px;margin-top:10px;font-weight:700;text-align:center";
791
+ var cont=document.getElementById("escenas");
792
+ if(cont){cont.appendChild(tot);}
793
+ }
794
+ tot.textContent="Total del video: "+sumaSeg+" segundos (voz: "+Math.round(durVoz)+"s). Ajusta si quieres.";
795
+ }
796
+ var pollTimer=null;
797
+ function recolectarEscenas(){
798
+ var items=document.getElementsByClassName("escenaItem");
799
+ var lista=[];
800
+ for(var i=0;i<items.length;i++){
801
+ var sel=items[i].getElementsByClassName("selMaterial")[0];
802
+ var mat=sel?sel.value:"";
803
+ var seg=items[i].getElementsByClassName("segEscena")[0];
804
+ var segs=seg?parseInt(seg.value):5;
805
+ if(!segs||segs<1){segs=5;}
806
+ var txt=items[i].getElementsByClassName("txtEscena")[0];
807
+ var texto=txt?txt.value:"";
808
+ lista.push({material:mat,segundos:segs,texto:texto});
809
+ }
810
+ return lista;
811
+ }
812
+ async function genVideo(){
813
+ var m=document.getElementById("msgVideo");
814
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
815
+ // Lanzar el render sin esperar la respuesta (evita timeout)
816
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
817
+ guion:document.getElementById("guion").value,
818
+ sub_activo:document.getElementById("subActivo").value==="si",
819
+ sub_tamano:document.getElementById("subTamano").value,
820
+ sub_posicion:document.getElementById("subPosicion").value,
821
+ sub_color:document.getElementById("subColor").value,
822
+ efecto:document.getElementById("efecto").value,
823
+ escenas:recolectarEscenas()
824
+ })}).catch(e=>{});
825
+ // Comprobar cada 4 segundos si el video esta listo
826
+ var inicio=Date.now();
827
+ if(pollTimer)clearInterval(pollTimer);
828
+ pollTimer=setInterval(async()=>{
829
+ try{
830
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
831
+ if(d.listo && d.mtime*1000 > inicio){
832
+ clearInterval(pollTimer);
833
+ var u="/video?t="+Date.now();
834
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
835
+ m.innerHTML="<span class=\'tick\'>✓</span> Video listo! <a href=\'"+u+"\' target=\'_blank\' style=\'display:inline-block;background:#00E5FF;color:#000;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:800;margin-top:8px\'>▶ VER VIDEO</a>";
836
+ m.style.color="#00c853";
837
+ }
838
+ }catch(e){}
839
+ },4000);
840
+ }
841
+ async function subirMusica(){
842
+ var f=document.getElementById("musica").files[0]; if(!f)return;
843
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
844
+ var fd=new FormData(); fd.append("file",f);
845
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
846
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
847
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
848
+ }
849
+ function cargarVideo(){
850
+ var u="/video?t="+Date.now();
851
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
852
+ var m=document.getElementById("msgVideo");
853
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
854
+ m.style.color="#00c853";
855
+ }
856
+ async function nuevoVideo(){
857
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
858
+ try{
859
+ await fetch("/reset",{method:"POST"});
860
+ location.reload();
861
+ }catch(e){alert("Error al limpiar");}
862
+ }
863
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
864
+ function dividirEscenas(){
865
+ var t=document.getElementById("guion").value;
866
+ var sep=String.fromCharCode(10)+String.fromCharCode(10);
867
+ var bloques=t.split(sep);
868
+ var cont=document.getElementById("escenas");
869
+ var html="";
870
+ var n=0;
871
+ for(var i=0;i<bloques.length;i++){
872
+ var b=bloques[i].trim();
873
+ if(b==""){continue;}
874
+ n++;
875
+ var opciones="<option value=>-- material automatico --</option>";
876
+ for(var j=0;j<ARCHIVOS_SUBIDOS.length;j++){ opciones+="<option value="+ARCHIVOS_SUBIDOS[j]+">"+ARCHIVOS_SUBIDOS[j]+"</option>"; }
877
+ html+="<div class=escenaItem><b>Escena "+n+"</b><br><textarea class=txtEscena style=width:100%;min-height:50px;background:#111;color:#fff;border:1px solid #333;border-radius:6px;padding:6px>"+b+"</textarea><br><select class=selMaterial>"+opciones+"</select> <input type=number class=segEscena min=1 value=5 style=width:70px> seg</div>";
878
+ }
879
+ if(n==0){html="<div style=color:#888>Separa los parrafos con una linea en blanco</div>";}
880
+ cont.innerHTML=html;
881
+ }
882
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
883
+
884
+ function cambioVoz(){
885
+ var t=document.getElementById("tipoVoz").value;
886
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
887
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
888
+ }
889
+ var mediaRec=null, chunks=[], grabando=false;
890
+ async function toggleRec(){
891
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
892
+ if(!grabando){
893
+ try{
894
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
895
+ mediaRec=new MediaRecorder(stream); chunks=[];
896
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
897
+ mediaRec.onstop=async()=>{
898
+ var blob=new Blob(chunks,{type:"audio/webm"});
899
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
900
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
901
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
902
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="✅ Tu voz lista, escuchala:";m.style.color="#00c853";}
903
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
904
+ };
905
+ mediaRec.start(); grabando=true;
906
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
907
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
908
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
909
+ }else{
910
+ mediaRec.stop(); grabando=false;
911
+ btn.textContent="Empezar a grabar"; btn.style.background="";
912
+ }
913
+ }
914
+ </script>
915
+ </body></html>"""
app_backup_seguro.py ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, glob, random, asyncio, base64
2
+ from fastapi import FastAPI, Request, UploadFile, File
3
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
4
+ from groq import Groq
5
+ import edge_tts, requests
6
+
7
+ # Servicio Veo independiente (no rompe nada si falla la importación)
8
+ try:
9
+ from veo_service import generar_clips_escenas
10
+ except Exception:
11
+ def generar_clips_escenas(escenas): return escenas
12
+
13
+ app = FastAPI()
14
+
15
+ 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]
16
+
17
+ CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026")
18
+
19
+ def verificar_clave(clave):
20
+ return clave == CLAVE_ACCESO
21
+
22
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
23
+
24
+ # Estado de la sesion (guarda guion editado y material entre pasos)
25
+ ESTADO = {
26
+ "guion": "",
27
+ "archivos": [],
28
+ "voz_lista": False,
29
+ "video_listo": False,
30
+ }
31
+
32
+ ESTILOS = {
33
+ "narrativo": "narrativo y emocional, como contando una historia",
34
+ "educativo": "educativo y claro, explicando con autoridad",
35
+ "motivador": "motivador e inspirador, que mueva a la accion",
36
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
37
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
38
+ }
39
+
40
+ VOCES = {
41
+ "es": {
42
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
43
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
44
+ },
45
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
46
+ }
47
+
48
+ def log(m): print(m, flush=True)
49
+
50
+ # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua.
51
+ # Si falla, devuelve el clip original (nunca rompe el montaje).
52
+ def quitar_marca_meta(path, _cache={}):
53
+ exts_v = (".mp4",".mov",".webm",".avi",".mkv")
54
+ exts_i = (".jpg",".jpeg",".png",".webp")
55
+ low = str(path).lower()
56
+ if not low.endswith(exts_v + exts_i):
57
+ return path
58
+ if path in _cache:
59
+ return _cache[path]
60
+ try:
61
+ import subprocess
62
+ dims = subprocess.run(
63
+ ["ffprobe","-v","error","-select_streams","v:0",
64
+ "-show_entries","stream=width,height","-of","csv=p=0:s=x", path],
65
+ capture_output=True, text=True).stdout.strip()
66
+ W, H = [int(v) for v in dims.split("x")[:2]]
67
+ # Tapar marca "Veo" (banda negra abajo-derecha) con "Teshua" dorado
68
+ es_video = low.endswith(exts_v)
69
+ salida = path + ("_clean.mp4" if es_video else "_clean.png")
70
+ fs = max(22, int(H * 0.030))
71
+ pad = int(fs * 0.5)
72
+ # Posicion: pegado abajo-derecha, dentro de la banda negra donde aparece "Veo"
73
+ tx = W - int(W * 0.02) # x: desde la derecha
74
+ ty = H - int(H * 0.018) # y: casi en el borde inferior
75
+ # 1) Caja negra que tapa "Veo" completamente
76
+ # 2) Borde dorado alrededor
77
+ # 3) Texto "Teshua" dorado encima
78
+ vf = (
79
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=black@1:t=fill,"
80
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=0xFFD700@1:t=2,"
81
+ f"drawtext=text='Teshuá':fontsize={fs}:fontcolor=0xFFD700:x=W-tw-{int(W*0.03)}:y=H-th-{int(H*0.012)}:font=serif:borderw=2:bordercolor=black"
82
+ )
83
+ cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf]
84
+ cmd += (["-c:a","copy",salida] if es_video else [salida])
85
+ subprocess.run(cmd, capture_output=True)
86
+ if os.path.exists(salida) and os.path.getsize(salida) > 0:
87
+ log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if os.path.exists('logo.png') else 'no'})")
88
+ _cache[path] = salida; return salida
89
+ except Exception as e:
90
+ log(f"marca error: {e}")
91
+ _cache[path] = path
92
+ return path
93
+
94
+ # ========== GUION ==========
95
+ def generar_guion(texto_base, estilo, duracion=60):
96
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
97
+ palabras = int(duracion * 2.4)
98
+ last_err = None
99
+ MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"]
100
+ 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."
101
+ # Probar cada modelo con cada key hasta que uno funcione
102
+ for modelo in MODELOS:
103
+ for k in GROQ_KEYS:
104
+ try:
105
+ client = Groq(api_key=k)
106
+ res = client.chat.completions.create(
107
+ messages=[{"role":"user","content":prompt}],
108
+ model=modelo, temperature=0.85
109
+ )
110
+ return res.choices[0].message.content.strip()
111
+ except Exception as e:
112
+ last_err = e
113
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
114
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
115
+ continue
116
+ raise last_err if last_err else Exception("Sin keys Groq")
117
+
118
+ # ========== VOZ ==========
119
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
120
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
121
+ if pais:
122
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
123
+ cod = cods.get(pais.lower(),"")
124
+ if cod:
125
+ filt = [v for v in voces if f"-{cod}-" in v]
126
+ if filt: voces = filt
127
+ voz_sel = random.choice(voces)
128
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
129
+ return voz_sel
130
+
131
+ # ========== VIDEO ==========
132
+ def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None):
133
+ subs = subs or {}
134
+ material_escenas = material_escenas or []
135
+ escenas_data = escenas_data or []
136
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
137
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
138
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
139
+
140
+ voz = AC("voz.mp3")
141
+ # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales
142
+ if material_escenas:
143
+ fondos = [m for m in material_escenas if os.path.exists(m)]
144
+ else:
145
+ fondos = []
146
+ # Respaldo: si no hay fondos de escenas validos, usar el material general
147
+ if not fondos:
148
+ fondos = archivos[:8] if archivos else []
149
+
150
+ # Si no hay material propio, fondo de Pexels
151
+ if not fondos:
152
+ try:
153
+ h = {"Authorization": PEXELS_KEY}
154
+ r = requests.get("https://api.pexels.com/videos/search?query=abstract&per_page=8", headers=h)
155
+ for i, v in enumerate(r.json().get("videos", [])[:6]):
156
+ u = v["video_files"][0]["link"]
157
+ with open(f"fondo{i}.mp4","wb") as f: f.write(requests.get(u, timeout=30).content)
158
+ fondos.append(f"fondo{i}.mp4")
159
+ except Exception as e:
160
+ log(f"Error pexels: {e}")
161
+
162
+ # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar ===
163
+ fondos = [quitar_marca_meta(f) for f in fondos]
164
+ if material_escenas:
165
+ material_escenas = [quitar_marca_meta(m) for m in material_escenas]
166
+ if escenas_data:
167
+ escenas_data = [(quitar_marca_meta(m) if m else m, s) for m, s in escenas_data]
168
+
169
+ # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz
170
+ if escenas_data:
171
+ suma_seg = sum(s for m,s in escenas_data)
172
+ dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200)
173
+ else:
174
+ dur_total = min(voz.duration, 1200)
175
+
176
+ # Si el material son SOLO videos, usar el video entero de fondo (no trocear)
177
+ solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos)
178
+ if solo_videos:
179
+ from moviepy.editor import concatenate_videoclips as _ccv
180
+ vclips = []
181
+ for fp in fondos:
182
+ try:
183
+ vc = VC(fp).without_audio()
184
+ vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080)
185
+ vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920)
186
+ vclips.append(vc)
187
+ except Exception as e:
188
+ log(f"Error video {fp}: {e}")
189
+ if vclips:
190
+ base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0]
191
+ # Repetir el video en bucle hasta cubrir toda la duracion (sin negro)
192
+ if base.duration < dur_total:
193
+ from moviepy.editor import concatenate_videoclips as _ccloop
194
+ repes = []
195
+ acumulado = 0
196
+ while acumulado < dur_total:
197
+ repes.append(base)
198
+ acumulado += base.duration
199
+ base = _ccloop(repes, method="compose").subclip(0, dur_total)
200
+ else:
201
+ base = base.subclip(0, dur_total)
202
+ clip = base
203
+ voz = voz.subclip(0, min(dur_total, voz.duration))
204
+ _USAR_VIDEO_ENTERO = True
205
+ else:
206
+ _USAR_VIDEO_ENTERO = False
207
+ else:
208
+ _USAR_VIDEO_ENTERO = False
209
+
210
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
211
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
212
+ dur_clip = dur_total / num_planos
213
+ # Si hay escenas con segundos, cada clip dura SUS segundos
214
+ # Solo usar segundos por escena si hay material asignado en las escenas
215
+ material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)]
216
+ usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO
217
+ if usar_seg_escena:
218
+ fondos_loop = [m for m,s in material_en_escenas]
219
+ segundos_loop = [s for m,s in material_en_escenas]
220
+ clips = []
221
+ idx_clip = -1
222
+ # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz
223
+ _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO
224
+ if _es_rafaga and fondos_loop:
225
+ dur_rafaga = 0.2
226
+ n_necesarias = int(dur_total / dur_rafaga) + 1
227
+ fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)]
228
+ segundos_loop = [dur_rafaga for _ in fondos_loop]
229
+ usar_seg_escena = True
230
+ for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []):
231
+ idx_clip += 1
232
+ d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip
233
+ try:
234
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
235
+ img = PILImage.open(fp).convert("RGB")
236
+ w, h = img.size
237
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
238
+ ratio = min(1080/w, 1920/h)
239
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
240
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
241
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
242
+ ic = IC(fp+"_r.jpg").set_duration(d_este)
243
+ if efecto in ("zoom","zoom_fundido"):
244
+ ic = ic.resize(lambda t: 1 + 0.04*t).set_duration(d_este)
245
+ ic = ic.set_duration(d_este)
246
+ clips.append(ic)
247
+ else:
248
+ vclip = VC(fp).without_audio()
249
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
250
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
251
+ vclip = vclip.loop(duration=d_este).set_duration(d_este)
252
+ clips.append(vclip)
253
+ except Exception as e:
254
+ log(f"Error clip {fp}: {e}")
255
+
256
+ if not _USAR_VIDEO_ENTERO:
257
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
258
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
259
+ from moviepy.editor import concatenate_videoclips as _cc
260
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
261
+ clip = _cc(clips_fx, method="compose", padding=-0.5)
262
+ else:
263
+ clip = concatenate_videoclips(clips, method="compose")
264
+ # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta
265
+ _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False
266
+ if _es_manual:
267
+ suma = sum(s for m,s in escenas_data if m and os.path.exists(m))
268
+ if clip.duration < suma:
269
+ # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro)
270
+ from moviepy.editor import ImageClip as _ICf
271
+ ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration)
272
+ from moviepy.editor import concatenate_videoclips as _ccf
273
+ clip = _ccf([clip, ultimo.set_pos("center")], method="compose")
274
+ else:
275
+ clip = clip.subclip(0, suma)
276
+ dur_total = suma
277
+ else:
278
+ # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar)
279
+ dur_total = clip.duration
280
+ voz = voz.subclip(0, min(dur_total, voz.duration))
281
+
282
+ import re as _re
283
+ sub_color = subs.get("color","#FFFFFF")
284
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
285
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
286
+ sub_activo = subs.get("activo",True)
287
+
288
+ # --- SUBTITULOS SINCRONIZADOS POR FRASE ---
289
+ # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea
290
+ crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()]
291
+ # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas
292
+ segmentos = []
293
+ for fr in crudo:
294
+ pal = fr.split()
295
+ if len(pal) <= 8:
296
+ segmentos.append(fr)
297
+ else:
298
+ for i in range(0, len(pal), 7):
299
+ segmentos.append(" ".join(pal[i:i+7]))
300
+ if not segmentos:
301
+ segmentos = [guion]
302
+ # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia)
303
+ total_pal = sum(max(len(s.split()), 1) for s in segmentos)
304
+ dur_voz = voz.duration
305
+ txts = []
306
+ if sub_activo:
307
+ t_cursor = 0.0
308
+ for s in segmentos:
309
+ d = dur_voz * (max(len(s.split()), 1) / total_pal)
310
+ txts.append(
311
+ TextClip(s.upper(), fontsize=sub_size, color=sub_color,
312
+ font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
313
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
314
+ ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True)
315
+ )
316
+ t_cursor += d
317
+
318
+ # Mezclar musica de fondo si el cliente la subio
319
+ import os as _os
320
+ audio_final = voz
321
+ if _os.path.exists("musica.mp3"):
322
+ try:
323
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
324
+ musica = _AC("musica.mp3").volumex(0.18) # musica bajita para no tapar la voz
325
+ if musica.duration > voz.duration:
326
+ musica = musica.subclip(0, voz.duration)
327
+ else:
328
+ musica = musica.audio_loop(duration=voz.duration)
329
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
330
+ except Exception as _em:
331
+ log(f"Error mezcla musica: {_em}")
332
+ audio_final = voz
333
+ final = CompositeVideoClip([clip]+txts).set_audio(audio_final)
334
+ import os as _os2
335
+ if _os2.path.exists("preview.mp4"):
336
+ _os2.remove("preview.mp4")
337
+ final.write_videofile("preview_tmp.mp4", fps=20, codec="libx264", preset="ultrafast", logger=None, threads=4, audio_codec="aac")
338
+ # Renombrar solo cuando esta 100% terminado (evita cargar video a medias)
339
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
340
+ return "preview.mp4"
341
+
342
+ # ========== ENDPOINTS ==========
343
+ @app.post("/login")
344
+ async def ep_login(request: Request):
345
+ d = await request.json()
346
+ clave = d.get("clave","").strip()
347
+ if verificar_clave(clave):
348
+ return {"ok": True}
349
+ return JSONResponse({"error":"Clave incorrecta"}, status_code=401)
350
+
351
+ @app.post("/generar-guion")
352
+ async def ep_guion(request: Request):
353
+ d = await request.json()
354
+ texto = d.get("texto","").strip()
355
+ estilo = d.get("estilo","narrativo")
356
+ duracion = int(d.get("duracion", 60) or 60)
357
+ parte = d.get("parte","auto")
358
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
359
+ completo = ESTADO.get("texto_completo","")
360
+ if completo and len(completo) > 6000:
361
+ if parte == "principio":
362
+ texto = completo[:6000]
363
+ elif parte == "mitad":
364
+ m = len(completo)//2
365
+ texto = completo[m-3000:m+3000]
366
+ elif parte == "final":
367
+ texto = completo[-6000:]
368
+ else: # auto = trozo al azar
369
+ import random as _r
370
+ ini = _r.randint(0, max(0, len(completo)-6000))
371
+ texto = completo[ini:ini+6000]
372
+ if not texto:
373
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
374
+ try:
375
+ guion = generar_guion(texto, estilo, duracion)
376
+ ESTADO["guion"] = guion
377
+ return {"guion": guion}
378
+ except Exception as e:
379
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
380
+
381
+ @app.post("/subir-material")
382
+ async def ep_material(file: UploadFile = File(...)):
383
+ try:
384
+ import subprocess as _sp, os as _os
385
+ ext = file.filename.split(".")[-1].lower()
386
+ idx = len(ESTADO['archivos'])
387
+ if ext in ("mp4","mov","webm","avi","mkv","m4v"):
388
+ crudo = f"crudo_{idx}.{ext}"
389
+ with open(crudo,"wb") as f:
390
+ f.write(await file.read())
391
+ nombre = f"media_{idx}.mp4"
392
+ # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros)
393
+ r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True)
394
+ if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000:
395
+ try: _os.remove(crudo)
396
+ except: pass
397
+ else:
398
+ nombre = crudo
399
+ else:
400
+ nombre = f"media_{idx}.{ext}"
401
+ with open(nombre,"wb") as f:
402
+ f.write(await file.read())
403
+ ESTADO["archivos"].append(nombre)
404
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
405
+ except Exception as e:
406
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
407
+
408
+ @app.post("/generar-voz")
409
+ async def ep_voz(request: Request):
410
+ d = await request.json()
411
+ guion = d.get("guion","").strip()
412
+ if not guion:
413
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
414
+ ESTADO["guion"] = guion
415
+ try:
416
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
417
+ ESTADO["voz_lista"] = True
418
+ return {"ok": True}
419
+ except Exception as e:
420
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
421
+
422
+
423
+ @app.post("/subir-voz-grabada")
424
+ async def ep_voz_grabada(file: UploadFile = File(...)):
425
+ try:
426
+ import subprocess
427
+ contenido = await file.read()
428
+ with open("voz_grabada_raw","wb") as f:
429
+ f.write(contenido)
430
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
431
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
432
+ import os as _os
433
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
434
+ ESTADO["voz_lista"] = True
435
+ return {"ok": True}
436
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
437
+ except Exception as e:
438
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
439
+
440
+
441
+ @app.post("/subir-musica")
442
+ async def ep_musica(file: UploadFile = File(...)):
443
+ try:
444
+ import subprocess, os as _os
445
+ contenido = await file.read()
446
+ with open("musica_raw","wb") as f:
447
+ f.write(contenido)
448
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
449
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
450
+ ESTADO["musica"] = True
451
+ return {"ok": True}
452
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
453
+ except Exception as e:
454
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
455
+
456
+ @app.get("/audio")
457
+ def ep_audio():
458
+ if os.path.exists("voz.mp3"):
459
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
460
+ return JSONResponse({"error":"sin audio"}, status_code=404)
461
+
462
+ @app.post("/generar-video")
463
+ async def ep_video(request: Request):
464
+ d = await request.json()
465
+ guion = d.get("guion","").strip() or ESTADO["guion"]
466
+ if not os.path.exists("voz.mp3"):
467
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
468
+ subs = {
469
+ "color": d.get("sub_color","#FFFFFF"),
470
+ "tamano": d.get("sub_tamano","mediano"),
471
+ "posicion": d.get("sub_posicion","abajo"),
472
+ "activo": d.get("sub_activo", True),
473
+ }
474
+ efecto = d.get("efecto","ninguno")
475
+ escenas = d.get("escenas", [])
476
+ # Modo híbrido: rellenar escenas vacías con clips de Veo
477
+ escenas = generar_clips_escenas(escenas)
478
+ # Lista de (material, segundos) de cada escena, en orden
479
+ escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas]
480
+ material_escenas = [m for m,s in escenas_data if m]
481
+ try:
482
+ loop = asyncio.get_event_loop()
483
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data)
484
+ ESTADO["video_listo"] = True
485
+ return {"ok": True}
486
+ except Exception as e:
487
+ import traceback
488
+ tb = traceback.format_exc()
489
+ print("ERROR VIDEO:", tb, flush=True)
490
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
491
+
492
+
493
+ @app.get("/video-listo")
494
+ def ep_video_listo():
495
+ import os as _os
496
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
497
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
498
+ return {"listo": False}
499
+
500
+ @app.get("/video")
501
+ def ep_video_file():
502
+ if os.path.exists("preview.mp4"):
503
+ return FileResponse("preview.mp4", media_type="video/mp4")
504
+ return JSONResponse({"error":"sin video"}, status_code=404)
505
+
506
+ @app.post("/reset")
507
+ def ep_reset():
508
+ for f in glob.glob("media_*")+glob.glob("fondo*")+["voz.mp3","preview.mp4"]:
509
+ try: os.remove(f)
510
+ except: pass
511
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
512
+ return {"ok": True}
513
+
514
+ @app.post("/subir-pdf")
515
+ async def ep_pdf(file: UploadFile = File(...)):
516
+ try:
517
+ contenido = await file.read()
518
+ with open("temp.pdf","wb") as f:
519
+ f.write(contenido)
520
+ from pypdf import PdfReader
521
+ reader = PdfReader("temp.pdf")
522
+ texto = ""
523
+ for pagina in reader.pages:
524
+ texto += (pagina.extract_text() or "") + "\n"
525
+ texto = texto.strip()
526
+ if not texto:
527
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
528
+ ESTADO["texto_completo"] = texto
529
+ # Mostrar preview de los primeros 6000 al usuario
530
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
531
+ except Exception as e:
532
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
533
+
534
+ # ========== INTERFAZ ==========
535
+ @app.get("/", response_class=HTMLResponse)
536
+ def home():
537
+ return """<!DOCTYPE html>
538
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
539
+ <title>Fenix Hybrid Engine</title>
540
+ <style>
541
+ *{box-sizing:border-box;margin:0;padding:0}
542
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
543
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
544
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
545
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
546
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
547
+ textarea,select,input{width:100%;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:8px;padding:12px;font-size:.95rem;font-family:inherit;margin-bottom:10px}
548
+ textarea{min-height:90px;resize:vertical}
549
+ button{background:linear-gradient(90deg,#ff6b00,#ffb700);color:#000;border:none;padding:14px 20px;border-radius:8px;font-weight:800;font-size:1rem;cursor:pointer;width:100%;margin-top:6px}
550
+ button:disabled{opacity:.4}
551
+ .btn2{background:#222;color:#fff;border:1px solid #444}
552
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
553
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
554
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
555
+ .row{display:flex;gap:8px}.row>*{flex:1}
556
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
557
+
558
+ .spinner{display:inline-block;width:24px;height:24px;border:3px solid #333;border-top:3px solid #ffb700;border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle;margin-right:8px}
559
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
560
+ .tick{display:inline-block;width:24px;height:24px;background:#00c853;border-radius:50%;color:#fff;text-align:center;line-height:24px;font-weight:900;margin-right:8px;vertical-align:middle}
561
+ </style></head><body>
562
+ <div id="loginOverlay" style="position:fixed;inset:0;background:#0a0a0a;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px">
563
+ <div style="font-size:2rem;font-weight:800;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px">Fenix Hybrid Engine</div>
564
+ <p style="color:#888;margin-bottom:24px;font-size:.95rem">Introduce tu clave de acceso</p>
565
+ <input type="password" id="claveInput" placeholder="Clave de acceso" style="width:100%;max-width:300px;background:#111;color:#fff;border:1px solid #333;border-radius:10px;padding:14px;font-size:1rem;margin-bottom:12px" onkeydown="if(event.key==='Enter')hacerLogin()">
566
+ <button onclick="hacerLogin()" style="width:100%;max-width:300px;background:linear-gradient(135deg,#ff6b00,#ff3d00);color:#fff;border:none;padding:14px;border-radius:10px;font-weight:800;font-size:1rem;cursor:pointer">Entrar</button>
567
+ <div id="loginMsg" style="color:#ff5252;margin-top:12px;font-size:.9rem;min-height:18px"></div>
568
+ </div>
569
+ <script>
570
+ async function hacerLogin(){
571
+ var clave=document.getElementById("claveInput").value;
572
+ var msg=document.getElementById("loginMsg");
573
+ msg.textContent="Comprobando...";msg.style.color="#ffb700";
574
+ try{
575
+ var r=await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clave:clave})});
576
+ var d=await r.json();
577
+ if(d.ok){document.getElementById("loginOverlay").style.display="none";}
578
+ else{msg.textContent="Clave incorrecta";msg.style.color="#ff5252";}
579
+ }catch(e){msg.textContent="Error";msg.style.color="#ff5252";}
580
+ }
581
+ </script>
582
+
583
+ <h1>Fenix Hybrid Engine</h1>
584
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
585
+ <button onclick="var p=document.getElementById('panelAyuda');p.style.display=(p.style.display=='none'||!p.style.display)?'block':'none';" style="background:#ff6b00;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-weight:700;cursor:pointer;margin:10px 0">Como usar (pulsa aqui)</button>
586
+ <div id="panelAyuda" style="display:none;background:#1a1a1a;border:1px solid #ff6b00;border-radius:10px;padding:16px;margin:10px 0;line-height:1.6">
587
+ <b>COMO HACER TU VIDEO (paso a paso):</b><br><br>
588
+ <b>1.</b> Sube un PDF o pega tu texto.<br>
589
+ <b>2.</b> Elige la duracion (15s, 30s, 45s, 1 o 2 min) y el estilo. Pulsa Generar Guion.<br>
590
+ <b>3.</b> Si quieres, edita el guion a tu gusto.<br>
591
+ <b>4.</b> Pulsa Dividir en escenas.<br>
592
+ <b>5.</b> Sube tus imagenes o videos.<br>
593
+ <b>6.</b> Genera la voz. Los segundos de cada escena se ponen SOLOS segun la voz, y abajo veras el total.<br>
594
+ <b>7.</b> Elige color de subtitulos y efecto si quieres.<br>
595
+ <b>8.</b> Pulsa Generar Video y descargalo. Sale perfecto, sin pantalla negra.<br><br>
596
+ <b>DOS MODOS DE MONTAR:</b><br>
597
+ - <b>AUTOMATICO (facil):</b> deja el material en automatico. El sistema reparte las imagenes solo. Ideal para ir rapido.<br>
598
+ - <b>MANUAL (control total):</b> asigna una imagen a cada escena. Tu decides que imagen va en cada parte.<br><br>
599
+ <b>CONSEJO IMPORTANTE:</b> los segundos salen solos al generar la voz, no tienes que calcular nada. Si quieres que una escena dure mas o menos, NO toques los segundos: edita su TEXTO (hazlo mas largo o mas corto) y vuelve a generar la voz. Los segundos se ajustan solos. Asi es mas facil y siempre cuadra.<br>
600
+ </div>
601
+
602
+ <div class="fase">
603
+ <div class="fase-t">1. Ingesta de conocimiento</div>
604
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
605
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
606
+ <div class="msg" id="msgPdf"></div>
607
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
608
+ <span class="lbl">Duracion del video:</span>
609
+ <select id="duracion">
610
+ <option value="15">15 segundos</option>
611
+ <option value="30">30 segundos</option>
612
+ <option value="45">45 segundos</option>
613
+ <option value="60">1 minuto</option>
614
+ <option value="120">2 minutos</option>
615
+ </select>
616
+ <span class="lbl">Estilo de narrativa:</span>
617
+ <select id="estilo">
618
+ <option value="narrativo">Narrativo y emocional</option>
619
+ <option value="educativo">Educativo y claro</option>
620
+ <option value="motivador">Motivador e inspirador</option>
621
+ <option value="publicitario">Publicitario y persuasivo</option>
622
+ <option value="misterio">Misterio e intriga</option>
623
+ </select>
624
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
625
+ <select id="parte">
626
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
627
+ <option value="principio">Principio del documento</option>
628
+ <option value="mitad">Mitad del documento</option>
629
+ <option value="final">Final del documento</option>
630
+ </select>
631
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
632
+ <div class="msg" id="msgGuion"></div>
633
+ </div>
634
+
635
+ <div class="fase">
636
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
637
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
638
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
639
+ <button onclick="dividirEscenas()" style="background:#1565c0;color:#fff;border:none;padding:10px;border-radius:8px;margin-top:8px;width:100%;font-weight:600">Dividir en escenas</button>
640
+ <div id="escenas" style="margin-top:12px"></div>
641
+ </div>
642
+
643
+ <div class="fase">
644
+ <div class="fase-t">3. Tu material</div>
645
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
646
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
647
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
648
+ </div>
649
+
650
+ <div class="fase">
651
+ <div class="fase-t">4. Voz</div>
652
+ <select id="tipoVoz" onchange="cambioVoz()">
653
+ <option value="ia">Voz IA (automatica)</option>
654
+ <option value="grabada">Mi voz grabada (con microfono)</option>
655
+ </select>
656
+ <div id="vozIA">
657
+ <div class="row">
658
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
659
+ <select id="pais"><option value="">Acento neutro</option><option value="españa">Espana</option><option value="mexico">Mexico</option><option value="argentina">Argentina</option><option value="colombia">Colombia</option></select>
660
+ </div>
661
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
662
+ </div>
663
+ <div id="vozGrabada" style="display:none">
664
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
665
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
666
+ </div>
667
+ <div class="msg" id="msgVoz"></div>
668
+ <audio id="player" controls style="display:none"></audio>
669
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
670
+ <span class="lbl">Musica de fondo (opcional, sonara bajita bajo la voz):</span>
671
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
672
+ <div class="msg" id="msgMusica"></div>
673
+ </div>
674
+ </div>
675
+
676
+ <div class="fase">
677
+ <div class="fase-t">5. Subtitulos y Montaje</div>
678
+ <div class="row">
679
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
680
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
681
+ </div>
682
+ <div class="row">
683
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
684
+ <select id="subColor"><option value="#FFFFFF">Blanco</option><option value="#C0C0C0">Plata</option><option value="#000000">Negro</option><option value="#FFD700">Dorado</option><option value="#FFFF00">Amarillo</option><option value="#FFA500">Naranja</option><option value="#FF0000">Rojo</option><option value="#FF4081">Rosa</option><option value="#FF00FF">Magenta</option><option value="#00E5FF">Cian</option><option value="#00BFFF">Azul cielo</option><option value="#1565C0">Azul</option><option value="#76FF03">Verde lima</option><option value="#00C853">Verde</option><option value="#9C27B0">Morado</option><option value="#FF6B00">Naranja fuego</option></select>
685
+ </div>
686
+ <span class="lbl">Efecto de video:</span>
687
+ <select id="efecto">
688
+ <option value="ninguno">Sin efecto (estatico)</option>
689
+ <option value="zoom">Zoom lento (Ken Burns)</option>
690
+ <option value="fundido">Fundido suave entre planos</option>
691
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
692
+ <option value="rafaga">Rafaga rapida (videoclip)</option>
693
+ </select>
694
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
695
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
696
+ <div class="msg" id="msgVideo"></div>
697
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
698
+ </div>
699
+
700
+ <div class="fase">
701
+ <div class="fase-t">6. Publicacion</div>
702
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
703
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">Nuevo Video (limpiar todo)</button>
704
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
705
+ <div class="msg" id="msgPub"></div>
706
+ </div>
707
+
708
+ <script>
709
+ var ARCHIVOS_SUBIDOS=[];
710
+ async function subirPdf(){
711
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
712
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
713
+ var fd=new FormData(); fd.append("file",f);
714
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
715
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
716
+ else{m.textContent=d.error;m.style.color="#ff5252";}
717
+ }
718
+ async function genGuion(){
719
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
720
+ var r=await fetch("/generar-guion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({texto:document.getElementById("texto").value,estilo:document.getElementById("estilo").value,parte:document.getElementById("parte").value,duracion:document.getElementById("duracion").value})});
721
+ var d=await r.json();
722
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
723
+ else{m.textContent=d.error;m.style.color="#ff5252";}
724
+ }
725
+ async function subirMaterial(){
726
+ var files=document.getElementById("material").files; if(!files.length)return;
727
+ var m=document.getElementById("msgMaterial");
728
+ var total=0;
729
+ for(var i=0;i<files.length;i++){
730
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
731
+ var fd=new FormData(); fd.append("file",files[i]);
732
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
733
+ if(d.ok){total=d.total; if(d.nombre){ARCHIVOS_SUBIDOS.push(d.nombre);}}
734
+ }
735
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
736
+ }
737
+ async function genVoz(){
738
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
739
+ var guionFinal=document.getElementById("guion").value;
740
+ var esc=recolectarEscenas();
741
+ if(esc.length>0){
742
+ var textos=[];
743
+ for(var k=0;k<esc.length;k++){ if(esc[k].texto){textos.push(esc[k].texto);} }
744
+ if(textos.length>0){guionFinal=textos.join(". ");}
745
+ }
746
+ var r=await fetch("/generar-voz",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({guion:guionFinal,genero:document.getElementById("genero").value,pais:document.getElementById("pais").value})});
747
+ var d=await r.json();
748
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="Voz lista, escuchala:";m.style.color="#00c853";
749
+ p.onloadedmetadata=function(){ sugerirSegundos(p.duration); };
750
+ }
751
+ else{m.textContent=d.error;m.style.color="#ff5252";}
752
+ }
753
+ function sugerirSegundos(durVoz){
754
+ if(!durVoz||durVoz<1){return;}
755
+ var items=document.getElementsByClassName("escenaItem");
756
+ if(items.length==0){return;}
757
+ // Contar palabras de cada escena y el total
758
+ var palabrasEsc=[]; var total=0;
759
+ for(var i=0;i<items.length;i++){
760
+ var t=items[i].getElementsByClassName("txtEscena")[0];
761
+ var np=t?t.value.trim().split(" ").filter(function(x){return x.length>0;}).length:1;
762
+ palabrasEsc.push(np); total+=np;
763
+ }
764
+ // Repartir la duracion de la voz segun palabras y rellenar
765
+ var sumaSeg=0;
766
+ for(var i=0;i<items.length;i++){
767
+ var seg=Math.max(1, Math.round(durVoz*(palabrasEsc[i]/total)));
768
+
769
+ sumaSeg+=seg;
770
+ var campo=items[i].getElementsByClassName("segEscena")[0];
771
+ if(campo){campo.value=seg;}
772
+ }
773
+ // Mostrar total abajo
774
+ var tot=document.getElementById("totalSeg");
775
+ if(!tot){
776
+ tot=document.createElement("div");
777
+ tot.id="totalSeg";
778
+ tot.style.cssText="background:#1565c0;color:#fff;padding:10px;border-radius:8px;margin-top:10px;font-weight:700;text-align:center";
779
+ var cont=document.getElementById("escenas");
780
+ if(cont){cont.appendChild(tot);}
781
+ }
782
+ tot.textContent="Total del video: "+sumaSeg+" segundos (voz: "+Math.round(durVoz)+"s). Ajusta si quieres.";
783
+ }
784
+ var pollTimer=null;
785
+ function recolectarEscenas(){
786
+ var items=document.getElementsByClassName("escenaItem");
787
+ var lista=[];
788
+ for(var i=0;i<items.length;i++){
789
+ var sel=items[i].getElementsByClassName("selMaterial")[0];
790
+ var mat=sel?sel.value:"";
791
+ var seg=items[i].getElementsByClassName("segEscena")[0];
792
+ var segs=seg?parseInt(seg.value):5;
793
+ if(!segs||segs<1){segs=5;}
794
+ var txt=items[i].getElementsByClassName("txtEscena")[0];
795
+ var texto=txt?txt.value:"";
796
+ lista.push({material:mat,segundos:segs,texto:texto});
797
+ }
798
+ return lista;
799
+ }
800
+ async function genVideo(){
801
+ var m=document.getElementById("msgVideo");
802
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
803
+ // Lanzar el render sin esperar la respuesta (evita timeout)
804
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
805
+ guion:document.getElementById("guion").value,
806
+ sub_activo:document.getElementById("subActivo").value==="si",
807
+ sub_tamano:document.getElementById("subTamano").value,
808
+ sub_posicion:document.getElementById("subPosicion").value,
809
+ sub_color:document.getElementById("subColor").value,
810
+ efecto:document.getElementById("efecto").value,
811
+ escenas:recolectarEscenas()
812
+ })}).catch(e=>{});
813
+ // Comprobar cada 4 segundos si el video esta listo
814
+ var inicio=Date.now();
815
+ if(pollTimer)clearInterval(pollTimer);
816
+ pollTimer=setInterval(async()=>{
817
+ try{
818
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
819
+ if(d.listo && d.mtime*1000 > inicio){
820
+ clearInterval(pollTimer);
821
+ var u="/video?t="+Date.now();
822
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
823
+ m.innerHTML="<span class=\'tick\'>✓</span> Video listo! <a href=\'"+u+"\' target=\'_blank\' style=\'display:inline-block;background:#00E5FF;color:#000;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:800;margin-top:8px\'>▶ VER VIDEO</a>";
824
+ m.style.color="#00c853";
825
+ }
826
+ }catch(e){}
827
+ },4000);
828
+ }
829
+ async function subirMusica(){
830
+ var f=document.getElementById("musica").files[0]; if(!f)return;
831
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
832
+ var fd=new FormData(); fd.append("file",f);
833
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
834
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
835
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
836
+ }
837
+ function cargarVideo(){
838
+ var u="/video?t="+Date.now();
839
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
840
+ var m=document.getElementById("msgVideo");
841
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
842
+ m.style.color="#00c853";
843
+ }
844
+ async function nuevoVideo(){
845
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
846
+ try{
847
+ await fetch("/reset",{method:"POST"});
848
+ location.reload();
849
+ }catch(e){alert("Error al limpiar");}
850
+ }
851
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
852
+ function dividirEscenas(){
853
+ var t=document.getElementById("guion").value;
854
+ var sep=String.fromCharCode(10)+String.fromCharCode(10);
855
+ var bloques=t.split(sep);
856
+ var cont=document.getElementById("escenas");
857
+ var html="";
858
+ var n=0;
859
+ for(var i=0;i<bloques.length;i++){
860
+ var b=bloques[i].trim();
861
+ if(b==""){continue;}
862
+ n++;
863
+ var opciones="<option value=>-- material automatico --</option>";
864
+ for(var j=0;j<ARCHIVOS_SUBIDOS.length;j++){ opciones+="<option value="+ARCHIVOS_SUBIDOS[j]+">"+ARCHIVOS_SUBIDOS[j]+"</option>"; }
865
+ html+="<div class=escenaItem><b>Escena "+n+"</b><br><textarea class=txtEscena style=width:100%;min-height:50px;background:#111;color:#fff;border:1px solid #333;border-radius:6px;padding:6px>"+b+"</textarea><br><select class=selMaterial>"+opciones+"</select> <input type=number class=segEscena min=1 value=5 style=width:70px> seg</div>";
866
+ }
867
+ if(n==0){html="<div style=color:#888>Separa los parrafos con una linea en blanco</div>";}
868
+ cont.innerHTML=html;
869
+ }
870
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
871
+
872
+ function cambioVoz(){
873
+ var t=document.getElementById("tipoVoz").value;
874
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
875
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
876
+ }
877
+ var mediaRec=null, chunks=[], grabando=false;
878
+ async function toggleRec(){
879
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
880
+ if(!grabando){
881
+ try{
882
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
883
+ mediaRec=new MediaRecorder(stream); chunks=[];
884
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
885
+ mediaRec.onstop=async()=>{
886
+ var blob=new Blob(chunks,{type:"audio/webm"});
887
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
888
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
889
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
890
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="✅ Tu voz lista, escuchala:";m.style.color="#00c853";}
891
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
892
+ };
893
+ mediaRec.start(); grabando=true;
894
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
895
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
896
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
897
+ }else{
898
+ mediaRec.stop(); grabando=false;
899
+ btn.textContent="Empezar a grabar"; btn.style.background="";
900
+ }
901
+ }
902
+ </script>
903
+ </body></html>"""
app_copia.txt ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, glob, random, asyncio, base64
2
+ from fastapi import FastAPI, Request, UploadFile, File
3
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
4
+ from groq import Groq
5
+ import edge_tts, requests
6
+
7
+ # Servicio Veo independiente (no rompe nada si falla la importación)
8
+ try:
9
+ from veo_service import generar_clips_escenas
10
+ except Exception:
11
+ def generar_clips_escenas(escenas): return escenas
12
+
13
+ app = FastAPI()
14
+
15
+ 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]
16
+
17
+ CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026")
18
+
19
+ def verificar_clave(clave):
20
+ return clave == CLAVE_ACCESO
21
+
22
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
23
+
24
+ # Estado de la sesion (guarda guion editado y material entre pasos)
25
+ ESTADO = {
26
+ "guion": "",
27
+ "archivos": [],
28
+ "voz_lista": False,
29
+ "video_listo": False,
30
+ }
31
+
32
+ ESTILOS = {
33
+ "narrativo": "narrativo y emocional, como contando una historia",
34
+ "educativo": "educativo y claro, explicando con autoridad",
35
+ "motivador": "motivador e inspirador, que mueva a la accion",
36
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
37
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
38
+ }
39
+
40
+ VOCES = {
41
+ "es": {
42
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
43
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
44
+ },
45
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
46
+ }
47
+
48
+ def log(m): print(m, flush=True)
49
+
50
+ # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua.
51
+ # Si falla, devuelve el clip original (nunca rompe el montaje).
52
+ def quitar_marca_meta(path, _cache={}):
53
+ exts_v = (".mp4",".mov",".webm",".avi",".mkv")
54
+ exts_i = (".jpg",".jpeg",".png",".webp")
55
+ low = str(path).lower()
56
+ if not low.endswith(exts_v + exts_i):
57
+ return path
58
+ if path in _cache:
59
+ return _cache[path]
60
+ try:
61
+ import subprocess
62
+ dims = subprocess.run(
63
+ ["ffprobe","-v","error","-select_streams","v:0",
64
+ "-show_entries","stream=width,height","-of","csv=p=0:s=x", path],
65
+ capture_output=True, text=True).stdout.strip()
66
+ W, H = [int(v) for v in dims.split("x")[:2]]
67
+ # Tapar marca "Veo" (banda negra abajo-derecha) con "Teshua" dorado
68
+ es_video = low.endswith(exts_v)
69
+ salida = path + ("_clean.mp4" if es_video else "_clean.png")
70
+ fs = max(22, int(H * 0.030))
71
+ pad = int(fs * 0.5)
72
+ # Posicion: pegado abajo-derecha, dentro de la banda negra donde aparece "Veo"
73
+ tx = W - int(W * 0.02) # x: desde la derecha
74
+ ty = H - int(H * 0.018) # y: casi en el borde inferior
75
+ # 1) Caja negra que tapa "Veo" completamente
76
+ # 2) Borde dorado alrededor
77
+ # 3) Texto "Teshua" dorado encima
78
+ vf = (
79
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=black@1:t=fill,"
80
+ f"drawbox=x={W - int(W*0.22)}:y={H - int(H*0.055)}:w={int(W*0.21)}:h={int(H*0.052)}:color=0xFFD700@1:t=2,"
81
+ f"drawtext=text='Teshuá':fontsize={fs}:fontcolor=0xFFD700:x=W-tw-{int(W*0.03)}:y=H-th-{int(H*0.012)}:font=serif:borderw=2:bordercolor=black"
82
+ )
83
+ cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf]
84
+ cmd += (["-c:a","copy",salida] if es_video else [salida])
85
+ subprocess.run(cmd, capture_output=True)
86
+ if os.path.exists(salida) and os.path.getsize(salida) > 0:
87
+ log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if os.path.exists('logo.png') else 'no'})")
88
+ _cache[path] = salida; return salida
89
+ except Exception as e:
90
+ log(f"marca error: {e}")
91
+ _cache[path] = path
92
+ return path
93
+
94
+ # ========== GUION ==========
95
+ def generar_guion(texto_base, estilo, duracion=60):
96
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
97
+ palabras = int(duracion * 2.4)
98
+ last_err = None
99
+ MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"]
100
+ 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."
101
+ # Probar cada modelo con cada key hasta que uno funcione
102
+ for modelo in MODELOS:
103
+ for k in GROQ_KEYS:
104
+ try:
105
+ client = Groq(api_key=k)
106
+ res = client.chat.completions.create(
107
+ messages=[{"role":"user","content":prompt}],
108
+ model=modelo, temperature=0.85
109
+ )
110
+ return res.choices[0].message.content.strip()
111
+ except Exception as e:
112
+ last_err = e
113
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
114
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
115
+ continue
116
+ raise last_err if last_err else Exception("Sin keys Groq")
117
+
118
+ # ========== VOZ ==========
119
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
120
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
121
+ if pais:
122
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
123
+ cod = cods.get(pais.lower(),"")
124
+ if cod:
125
+ filt = [v for v in voces if f"-{cod}-" in v]
126
+ if filt: voces = filt
127
+ voz_sel = random.choice(voces)
128
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
129
+ return voz_sel
130
+
131
+ # ========== VIDEO ==========
132
+ def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None):
133
+ subs = subs or {}
134
+ material_escenas = material_escenas or []
135
+ escenas_data = escenas_data or []
136
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
137
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
138
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
139
+
140
+ voz = AC("voz.mp3")
141
+ # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales
142
+ if material_escenas:
143
+ fondos = [m for m in material_escenas if os.path.exists(m)]
144
+ else:
145
+ fondos = []
146
+ # Respaldo: si no hay fondos de escenas validos, usar el material general
147
+ if not fondos:
148
+ fondos = archivos[:8] if archivos else []
149
+
150
+ # Si no hay material propio, fondo de Pexels
151
+ if not fondos:
152
+ try:
153
+ h = {"Authorization": PEXELS_KEY}
154
+ r = requests.get("https://api.pexels.com/videos/search?query=abstract&per_page=8", headers=h)
155
+ for i, v in enumerate(r.json().get("videos", [])[:6]):
156
+ u = v["video_files"][0]["link"]
157
+ with open(f"fondo{i}.mp4","wb") as f: f.write(requests.get(u, timeout=30).content)
158
+ fondos.append(f"fondo{i}.mp4")
159
+ except Exception as e:
160
+ log(f"Error pexels: {e}")
161
+
162
+ # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar ===
163
+ fondos = [quitar_marca_meta(f) for f in fondos]
164
+ if material_escenas:
165
+ material_escenas = [quitar_marca_meta(m) for m in material_escenas]
166
+ if escenas_data:
167
+ escenas_data = [(quitar_marca_meta(m) if m else m, s) for m, s in escenas_data]
168
+
169
+ # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz
170
+ if escenas_data:
171
+ suma_seg = sum(s for m,s in escenas_data)
172
+ dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200)
173
+ else:
174
+ dur_total = min(voz.duration, 1200)
175
+
176
+ # Si el material son SOLO videos, usar el video entero de fondo (no trocear)
177
+ solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos)
178
+ if solo_videos:
179
+ from moviepy.editor import concatenate_videoclips as _ccv
180
+ vclips = []
181
+ for fp in fondos:
182
+ try:
183
+ vc = VC(fp).without_audio()
184
+ vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080)
185
+ vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920)
186
+ vclips.append(vc)
187
+ except Exception as e:
188
+ log(f"Error video {fp}: {e}")
189
+ if vclips:
190
+ base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0]
191
+ # Repetir el video en bucle hasta cubrir toda la duracion (sin negro)
192
+ if base.duration < dur_total:
193
+ from moviepy.editor import concatenate_videoclips as _ccloop
194
+ repes = []
195
+ acumulado = 0
196
+ while acumulado < dur_total:
197
+ repes.append(base)
198
+ acumulado += base.duration
199
+ base = _ccloop(repes, method="compose").subclip(0, dur_total)
200
+ else:
201
+ base = base.subclip(0, dur_total)
202
+ clip = base
203
+ voz = voz.subclip(0, min(dur_total, voz.duration))
204
+ _USAR_VIDEO_ENTERO = True
205
+ else:
206
+ _USAR_VIDEO_ENTERO = False
207
+ else:
208
+ _USAR_VIDEO_ENTERO = False
209
+
210
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
211
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
212
+ dur_clip = dur_total / num_planos
213
+ # Si hay escenas con segundos, cada clip dura SUS segundos
214
+ # Solo usar segundos por escena si hay material asignado en las escenas
215
+ material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)]
216
+ usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO
217
+ if usar_seg_escena:
218
+ fondos_loop = [m for m,s in material_en_escenas]
219
+ segundos_loop = [s for m,s in material_en_escenas]
220
+ clips = []
221
+ idx_clip = -1
222
+ # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz
223
+ _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO
224
+ if _es_rafaga and fondos_loop:
225
+ dur_rafaga = 0.2
226
+ n_necesarias = int(dur_total / dur_rafaga) + 1
227
+ fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)]
228
+ segundos_loop = [dur_rafaga for _ in fondos_loop]
229
+ usar_seg_escena = True
230
+ for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []):
231
+ idx_clip += 1
232
+ d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip
233
+ try:
234
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
235
+ img = PILImage.open(fp).convert("RGB")
236
+ w, h = img.size
237
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
238
+ ratio = min(1080/w, 1920/h)
239
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
240
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
241
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
242
+ ic = IC(fp+"_r.jpg").set_duration(d_este)
243
+ if efecto in ("zoom","zoom_fundido"):
244
+ ic = ic.resize(lambda t: 1 + 0.04*t).set_duration(d_este)
245
+ ic = ic.set_duration(d_este)
246
+ clips.append(ic)
247
+ else:
248
+ vclip = VC(fp).without_audio()
249
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
250
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
251
+ vclip = vclip.loop(duration=d_este).set_duration(d_este)
252
+ clips.append(vclip)
253
+ except Exception as e:
254
+ log(f"Error clip {fp}: {e}")
255
+
256
+ if not _USAR_VIDEO_ENTERO:
257
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
258
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
259
+ from moviepy.editor import concatenate_videoclips as _cc
260
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
261
+ clip = _cc(clips_fx, method="compose", padding=-0.5)
262
+ else:
263
+ clip = concatenate_videoclips(clips, method="compose")
264
+ # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta
265
+ _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False
266
+ if _es_manual:
267
+ suma = sum(s for m,s in escenas_data if m and os.path.exists(m))
268
+ if clip.duration < suma:
269
+ # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro)
270
+ from moviepy.editor import ImageClip as _ICf
271
+ ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration)
272
+ from moviepy.editor import concatenate_videoclips as _ccf
273
+ clip = _ccf([clip, ultimo.set_pos("center")], method="compose")
274
+ else:
275
+ clip = clip.subclip(0, suma)
276
+ dur_total = suma
277
+ else:
278
+ # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar)
279
+ dur_total = clip.duration
280
+ voz = voz.subclip(0, min(dur_total, voz.duration))
281
+
282
+ import re as _re
283
+ sub_color = subs.get("color","#FFFFFF")
284
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
285
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
286
+ sub_activo = subs.get("activo",True)
287
+
288
+ # --- SUBTITULOS SINCRONIZADOS POR FRASE ---
289
+ # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea
290
+ crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()]
291
+ # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas
292
+ segmentos = []
293
+ for fr in crudo:
294
+ pal = fr.split()
295
+ if len(pal) <= 8:
296
+ segmentos.append(fr)
297
+ else:
298
+ for i in range(0, len(pal), 7):
299
+ segmentos.append(" ".join(pal[i:i+7]))
300
+ if not segmentos:
301
+ segmentos = [guion]
302
+ # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia)
303
+ total_pal = sum(max(len(s.split()), 1) for s in segmentos)
304
+ dur_voz = voz.duration
305
+ txts = []
306
+ if sub_activo:
307
+ t_cursor = 0.0
308
+ for s in segmentos:
309
+ d = dur_voz * (max(len(s.split()), 1) / total_pal)
310
+ txts.append(
311
+ TextClip(s.upper(), fontsize=sub_size, color=sub_color,
312
+ font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
313
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
314
+ ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True)
315
+ )
316
+ t_cursor += d
317
+
318
+ # Mezclar musica de fondo si el cliente la subio
319
+ import os as _os
320
+ audio_final = voz
321
+ if _os.path.exists("musica.mp3"):
322
+ try:
323
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
324
+ musica = _AC("musica.mp3").volumex(0.18) # musica bajita para no tapar la voz
325
+ if musica.duration > voz.duration:
326
+ musica = musica.subclip(0, voz.duration)
327
+ else:
328
+ musica = musica.audio_loop(duration=voz.duration)
329
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
330
+ except Exception as _em:
331
+ log(f"Error mezcla musica: {_em}")
332
+ audio_final = voz
333
+ final = CompositeVideoClip([clip]+txts).set_audio(audio_final)
334
+ import os as _os2
335
+ if _os2.path.exists("preview.mp4"):
336
+ _os2.remove("preview.mp4")
337
+ final.write_videofile("preview_tmp.mp4", fps=20, codec="libx264", preset="ultrafast", logger=None, threads=4, audio_codec="aac")
338
+ # Renombrar solo cuando esta 100% terminado (evita cargar video a medias)
339
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
340
+ return "preview.mp4"
341
+
342
+ # ========== ENDPOINTS ==========
343
+ @app.post("/login")
344
+ async def ep_login(request: Request):
345
+ d = await request.json()
346
+ clave = d.get("clave","").strip()
347
+ if verificar_clave(clave):
348
+ return {"ok": True}
349
+ return JSONResponse({"error":"Clave incorrecta"}, status_code=401)
350
+
351
+ @app.post("/generar-guion")
352
+ async def ep_guion(request: Request):
353
+ d = await request.json()
354
+ texto = d.get("texto","").strip()
355
+ estilo = d.get("estilo","narrativo")
356
+ duracion = int(d.get("duracion", 60) or 60)
357
+ parte = d.get("parte","auto")
358
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
359
+ completo = ESTADO.get("texto_completo","")
360
+ if completo and len(completo) > 6000:
361
+ if parte == "principio":
362
+ texto = completo[:6000]
363
+ elif parte == "mitad":
364
+ m = len(completo)//2
365
+ texto = completo[m-3000:m+3000]
366
+ elif parte == "final":
367
+ texto = completo[-6000:]
368
+ else: # auto = trozo al azar
369
+ import random as _r
370
+ ini = _r.randint(0, max(0, len(completo)-6000))
371
+ texto = completo[ini:ini+6000]
372
+ if not texto:
373
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
374
+ try:
375
+ guion = generar_guion(texto, estilo, duracion)
376
+ ESTADO["guion"] = guion
377
+ return {"guion": guion}
378
+ except Exception as e:
379
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
380
+
381
+ @app.post("/subir-material")
382
+ async def ep_material(file: UploadFile = File(...)):
383
+ try:
384
+ import subprocess as _sp, os as _os
385
+ ext = file.filename.split(".")[-1].lower()
386
+ idx = len(ESTADO['archivos'])
387
+ if ext in ("mp4","mov","webm","avi","mkv","m4v"):
388
+ crudo = f"crudo_{idx}.{ext}"
389
+ with open(crudo,"wb") as f:
390
+ f.write(await file.read())
391
+ nombre = f"media_{idx}.mp4"
392
+ # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros)
393
+ r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True)
394
+ if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000:
395
+ try: _os.remove(crudo)
396
+ except: pass
397
+ else:
398
+ nombre = crudo
399
+ else:
400
+ nombre = f"media_{idx}.{ext}"
401
+ with open(nombre,"wb") as f:
402
+ f.write(await file.read())
403
+ ESTADO["archivos"].append(nombre)
404
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
405
+ except Exception as e:
406
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
407
+
408
+ @app.post("/generar-voz")
409
+ async def ep_voz(request: Request):
410
+ d = await request.json()
411
+ guion = d.get("guion","").strip()
412
+ if not guion:
413
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
414
+ ESTADO["guion"] = guion
415
+ try:
416
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
417
+ ESTADO["voz_lista"] = True
418
+ return {"ok": True}
419
+ except Exception as e:
420
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
421
+
422
+
423
+ @app.post("/subir-voz-grabada")
424
+ async def ep_voz_grabada(file: UploadFile = File(...)):
425
+ try:
426
+ import subprocess
427
+ contenido = await file.read()
428
+ with open("voz_grabada_raw","wb") as f:
429
+ f.write(contenido)
430
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
431
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
432
+ import os as _os
433
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
434
+ ESTADO["voz_lista"] = True
435
+ return {"ok": True}
436
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
437
+ except Exception as e:
438
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
439
+
440
+
441
+ @app.post("/subir-musica")
442
+ async def ep_musica(file: UploadFile = File(...)):
443
+ try:
444
+ import subprocess, os as _os
445
+ contenido = await file.read()
446
+ with open("musica_raw","wb") as f:
447
+ f.write(contenido)
448
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
449
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
450
+ ESTADO["musica"] = True
451
+ return {"ok": True}
452
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
453
+ except Exception as e:
454
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
455
+
456
+ @app.get("/audio")
457
+ def ep_audio():
458
+ if os.path.exists("voz.mp3"):
459
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
460
+ return JSONResponse({"error":"sin audio"}, status_code=404)
461
+
462
+ @app.post("/generar-video")
463
+ async def ep_video(request: Request):
464
+ d = await request.json()
465
+ guion = d.get("guion","").strip() or ESTADO["guion"]
466
+ if not os.path.exists("voz.mp3"):
467
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
468
+ subs = {
469
+ "color": d.get("sub_color","#FFFFFF"),
470
+ "tamano": d.get("sub_tamano","mediano"),
471
+ "posicion": d.get("sub_posicion","abajo"),
472
+ "activo": d.get("sub_activo", True),
473
+ }
474
+ efecto = d.get("efecto","ninguno")
475
+ escenas = d.get("escenas", [])
476
+ # Modo híbrido: rellenar escenas vacías con clips de Veo
477
+ escenas = generar_clips_escenas(escenas)
478
+ # Lista de (material, segundos) de cada escena, en orden
479
+ escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas]
480
+ material_escenas = [m for m,s in escenas_data if m]
481
+ try:
482
+ loop = asyncio.get_event_loop()
483
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data)
484
+ ESTADO["video_listo"] = True
485
+ return {"ok": True}
486
+ except Exception as e:
487
+ import traceback
488
+ tb = traceback.format_exc()
489
+ print("ERROR VIDEO:", tb, flush=True)
490
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
491
+
492
+
493
+ @app.get("/video-listo")
494
+ def ep_video_listo():
495
+ import os as _os
496
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
497
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
498
+ return {"listo": False}
499
+
500
+ @app.get("/video")
501
+ def ep_video_file():
502
+ if os.path.exists("preview.mp4"):
503
+ return FileResponse("preview.mp4", media_type="video/mp4")
504
+ return JSONResponse({"error":"sin video"}, status_code=404)
505
+
506
+ @app.post("/reset")
507
+ def ep_reset():
508
+ for f in glob.glob("media_*")+glob.glob("fondo*")+["voz.mp3","preview.mp4"]:
509
+ try: os.remove(f)
510
+ except: pass
511
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
512
+ return {"ok": True}
513
+
514
+ @app.post("/subir-pdf")
515
+ async def ep_pdf(file: UploadFile = File(...)):
516
+ try:
517
+ contenido = await file.read()
518
+ with open("temp.pdf","wb") as f:
519
+ f.write(contenido)
520
+ from pypdf import PdfReader
521
+ reader = PdfReader("temp.pdf")
522
+ texto = ""
523
+ for pagina in reader.pages:
524
+ texto += (pagina.extract_text() or "") + "\n"
525
+ texto = texto.strip()
526
+ if not texto:
527
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
528
+ ESTADO["texto_completo"] = texto
529
+ # Mostrar preview de los primeros 6000 al usuario
530
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
531
+ except Exception as e:
532
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
533
+
534
+ # ========== INTERFAZ ==========
535
+ @app.get("/", response_class=HTMLResponse)
536
+ def home():
537
+ return """<!DOCTYPE html>
538
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
539
+ <title>Fenix Hybrid Engine</title>
540
+ <style>
541
+ *{box-sizing:border-box;margin:0;padding:0}
542
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
543
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
544
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
545
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
546
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
547
+ textarea,select,input{width:100%;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:8px;padding:12px;font-size:.95rem;font-family:inherit;margin-bottom:10px}
548
+ textarea{min-height:90px;resize:vertical}
549
+ button{background:linear-gradient(90deg,#ff6b00,#ffb700);color:#000;border:none;padding:14px 20px;border-radius:8px;font-weight:800;font-size:1rem;cursor:pointer;width:100%;margin-top:6px}
550
+ button:disabled{opacity:.4}
551
+ .btn2{background:#222;color:#fff;border:1px solid #444}
552
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
553
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
554
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
555
+ .row{display:flex;gap:8px}.row>*{flex:1}
556
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
557
+
558
+ .spinner{display:inline-block;width:24px;height:24px;border:3px solid #333;border-top:3px solid #ffb700;border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle;margin-right:8px}
559
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
560
+ .tick{display:inline-block;width:24px;height:24px;background:#00c853;border-radius:50%;color:#fff;text-align:center;line-height:24px;font-weight:900;margin-right:8px;vertical-align:middle}
561
+ </style></head><body>
562
+ <div id="loginOverlay" style="position:fixed;inset:0;background:#0a0a0a;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px">
563
+ <div style="font-size:2rem;font-weight:800;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px">Fenix Hybrid Engine</div>
564
+ <p style="color:#888;margin-bottom:24px;font-size:.95rem">Introduce tu clave de acceso</p>
565
+ <input type="password" id="claveInput" placeholder="Clave de acceso" style="width:100%;max-width:300px;background:#111;color:#fff;border:1px solid #333;border-radius:10px;padding:14px;font-size:1rem;margin-bottom:12px" onkeydown="if(event.key==='Enter')hacerLogin()">
566
+ <button onclick="hacerLogin()" style="width:100%;max-width:300px;background:linear-gradient(135deg,#ff6b00,#ff3d00);color:#fff;border:none;padding:14px;border-radius:10px;font-weight:800;font-size:1rem;cursor:pointer">Entrar</button>
567
+ <div id="loginMsg" style="color:#ff5252;margin-top:12px;font-size:.9rem;min-height:18px"></div>
568
+ </div>
569
+ <script>
570
+ async function hacerLogin(){
571
+ var clave=document.getElementById("claveInput").value;
572
+ var msg=document.getElementById("loginMsg");
573
+ msg.textContent="Comprobando...";msg.style.color="#ffb700";
574
+ try{
575
+ var r=await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clave:clave})});
576
+ var d=await r.json();
577
+ if(d.ok){document.getElementById("loginOverlay").style.display="none";}
578
+ else{msg.textContent="Clave incorrecta";msg.style.color="#ff5252";}
579
+ }catch(e){msg.textContent="Error";msg.style.color="#ff5252";}
580
+ }
581
+ </script>
582
+
583
+ <h1>Fenix Hybrid Engine</h1>
584
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
585
+ <button onclick="var p=document.getElementById('panelAyuda');p.style.display=(p.style.display=='none'||!p.style.display)?'block':'none';" style="background:#ff6b00;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-weight:700;cursor:pointer;margin:10px 0">Como usar (pulsa aqui)</button>
586
+ <div id="panelAyuda" style="display:none;background:#1a1a1a;border:1px solid #ff6b00;border-radius:10px;padding:16px;margin:10px 0;line-height:1.6">
587
+ <b>COMO HACER TU VIDEO (paso a paso):</b><br><br>
588
+ <b>1.</b> Sube un PDF o pega tu texto.<br>
589
+ <b>2.</b> Elige la duracion (15s, 30s, 45s, 1 o 2 min) y el estilo. Pulsa Generar Guion.<br>
590
+ <b>3.</b> Si quieres, edita el guion a tu gusto.<br>
591
+ <b>4.</b> Pulsa Dividir en escenas.<br>
592
+ <b>5.</b> Sube tus imagenes o videos.<br>
593
+ <b>6.</b> Genera la voz. Los segundos de cada escena se ponen SOLOS segun la voz, y abajo veras el total.<br>
594
+ <b>7.</b> Elige color de subtitulos y efecto si quieres.<br>
595
+ <b>8.</b> Pulsa Generar Video y descargalo. Sale perfecto, sin pantalla negra.<br><br>
596
+ <b>DOS MODOS DE MONTAR:</b><br>
597
+ - <b>AUTOMATICO (facil):</b> deja el material en automatico. El sistema reparte las imagenes solo. Ideal para ir rapido.<br>
598
+ - <b>MANUAL (control total):</b> asigna una imagen a cada escena. Tu decides que imagen va en cada parte.<br><br>
599
+ <b>CONSEJO IMPORTANTE:</b> los segundos salen solos al generar la voz, no tienes que calcular nada. Si quieres que una escena dure mas o menos, NO toques los segundos: edita su TEXTO (hazlo mas largo o mas corto) y vuelve a generar la voz. Los segundos se ajustan solos. Asi es mas facil y siempre cuadra.<br>
600
+ </div>
601
+
602
+ <div class="fase">
603
+ <div class="fase-t">1. Ingesta de conocimiento</div>
604
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
605
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
606
+ <div class="msg" id="msgPdf"></div>
607
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
608
+ <span class="lbl">Duracion del video:</span>
609
+ <select id="duracion">
610
+ <option value="15">15 segundos</option>
611
+ <option value="30">30 segundos</option>
612
+ <option value="45">45 segundos</option>
613
+ <option value="60">1 minuto</option>
614
+ <option value="120">2 minutos</option>
615
+ </select>
616
+ <span class="lbl">Estilo de narrativa:</span>
617
+ <select id="estilo">
618
+ <option value="narrativo">Narrativo y emocional</option>
619
+ <option value="educativo">Educativo y claro</option>
620
+ <option value="motivador">Motivador e inspirador</option>
621
+ <option value="publicitario">Publicitario y persuasivo</option>
622
+ <option value="misterio">Misterio e intriga</option>
623
+ </select>
624
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
625
+ <select id="parte">
626
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
627
+ <option value="principio">Principio del documento</option>
628
+ <option value="mitad">Mitad del documento</option>
629
+ <option value="final">Final del documento</option>
630
+ </select>
631
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
632
+ <div class="msg" id="msgGuion"></div>
633
+ </div>
634
+
635
+ <div class="fase">
636
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
637
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
638
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
639
+ <button onclick="dividirEscenas()" style="background:#1565c0;color:#fff;border:none;padding:10px;border-radius:8px;margin-top:8px;width:100%;font-weight:600">Dividir en escenas</button>
640
+ <div id="escenas" style="margin-top:12px"></div>
641
+ </div>
642
+
643
+ <div class="fase">
644
+ <div class="fase-t">3. Tu material</div>
645
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
646
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
647
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
648
+ </div>
649
+
650
+ <div class="fase">
651
+ <div class="fase-t">4. Voz</div>
652
+ <select id="tipoVoz" onchange="cambioVoz()">
653
+ <option value="ia">Voz IA (automatica)</option>
654
+ <option value="grabada">Mi voz grabada (con microfono)</option>
655
+ </select>
656
+ <div id="vozIA">
657
+ <div class="row">
658
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
659
+ <select id="pais"><option value="">Acento neutro</option><option value="españa">Espana</option><option value="mexico">Mexico</option><option value="argentina">Argentina</option><option value="colombia">Colombia</option></select>
660
+ </div>
661
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
662
+ </div>
663
+ <div id="vozGrabada" style="display:none">
664
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
665
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
666
+ </div>
667
+ <div class="msg" id="msgVoz"></div>
668
+ <audio id="player" controls style="display:none"></audio>
669
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
670
+ <span class="lbl">Musica de fondo (opcional, sonara bajita bajo la voz):</span>
671
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
672
+ <div class="msg" id="msgMusica"></div>
673
+ </div>
674
+ </div>
675
+
676
+ <div class="fase">
677
+ <div class="fase-t">5. Subtitulos y Montaje</div>
678
+ <div class="row">
679
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
680
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
681
+ </div>
682
+ <div class="row">
683
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
684
+ <select id="subColor"><option value="#FFFFFF">Blanco</option><option value="#C0C0C0">Plata</option><option value="#000000">Negro</option><option value="#FFD700">Dorado</option><option value="#FFFF00">Amarillo</option><option value="#FFA500">Naranja</option><option value="#FF0000">Rojo</option><option value="#FF4081">Rosa</option><option value="#FF00FF">Magenta</option><option value="#00E5FF">Cian</option><option value="#00BFFF">Azul cielo</option><option value="#1565C0">Azul</option><option value="#76FF03">Verde lima</option><option value="#00C853">Verde</option><option value="#9C27B0">Morado</option><option value="#FF6B00">Naranja fuego</option></select>
685
+ </div>
686
+ <span class="lbl">Efecto de video:</span>
687
+ <select id="efecto">
688
+ <option value="ninguno">Sin efecto (estatico)</option>
689
+ <option value="zoom">Zoom lento (Ken Burns)</option>
690
+ <option value="fundido">Fundido suave entre planos</option>
691
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
692
+ <option value="rafaga">Rafaga rapida (videoclip)</option>
693
+ </select>
694
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
695
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
696
+ <div class="msg" id="msgVideo"></div>
697
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
698
+ </div>
699
+
700
+ <div class="fase">
701
+ <div class="fase-t">6. Publicacion</div>
702
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
703
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">Nuevo Video (limpiar todo)</button>
704
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
705
+ <div class="msg" id="msgPub"></div>
706
+ </div>
707
+
708
+ <script>
709
+ var ARCHIVOS_SUBIDOS=[];
710
+ async function subirPdf(){
711
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
712
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
713
+ var fd=new FormData(); fd.append("file",f);
714
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
715
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
716
+ else{m.textContent=d.error;m.style.color="#ff5252";}
717
+ }
718
+ async function genGuion(){
719
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
720
+ var r=await fetch("/generar-guion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({texto:document.getElementById("texto").value,estilo:document.getElementById("estilo").value,parte:document.getElementById("parte").value,duracion:document.getElementById("duracion").value})});
721
+ var d=await r.json();
722
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
723
+ else{m.textContent=d.error;m.style.color="#ff5252";}
724
+ }
725
+ async function subirMaterial(){
726
+ var files=document.getElementById("material").files; if(!files.length)return;
727
+ var m=document.getElementById("msgMaterial");
728
+ var total=0;
729
+ for(var i=0;i<files.length;i++){
730
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
731
+ var fd=new FormData(); fd.append("file",files[i]);
732
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
733
+ if(d.ok){total=d.total; if(d.nombre){ARCHIVOS_SUBIDOS.push(d.nombre);}}
734
+ }
735
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
736
+ }
737
+ async function genVoz(){
738
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
739
+ var guionFinal=document.getElementById("guion").value;
740
+ var esc=recolectarEscenas();
741
+ if(esc.length>0){
742
+ var textos=[];
743
+ for(var k=0;k<esc.length;k++){ if(esc[k].texto){textos.push(esc[k].texto);} }
744
+ if(textos.length>0){guionFinal=textos.join(". ");}
745
+ }
746
+ var r=await fetch("/generar-voz",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({guion:guionFinal,genero:document.getElementById("genero").value,pais:document.getElementById("pais").value})});
747
+ var d=await r.json();
748
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="Voz lista, escuchala:";m.style.color="#00c853";
749
+ p.onloadedmetadata=function(){ sugerirSegundos(p.duration); };
750
+ }
751
+ else{m.textContent=d.error;m.style.color="#ff5252";}
752
+ }
753
+ function sugerirSegundos(durVoz){
754
+ if(!durVoz||durVoz<1){return;}
755
+ var items=document.getElementsByClassName("escenaItem");
756
+ if(items.length==0){return;}
757
+ // Contar palabras de cada escena y el total
758
+ var palabrasEsc=[]; var total=0;
759
+ for(var i=0;i<items.length;i++){
760
+ var t=items[i].getElementsByClassName("txtEscena")[0];
761
+ var np=t?t.value.trim().split(" ").filter(function(x){return x.length>0;}).length:1;
762
+ palabrasEsc.push(np); total+=np;
763
+ }
764
+ // Repartir la duracion de la voz segun palabras y rellenar
765
+ var sumaSeg=0;
766
+ for(var i=0;i<items.length;i++){
767
+ var seg=Math.max(1, Math.round(durVoz*(palabrasEsc[i]/total)));
768
+
769
+ sumaSeg+=seg;
770
+ var campo=items[i].getElementsByClassName("segEscena")[0];
771
+ if(campo){campo.value=seg;}
772
+ }
773
+ // Mostrar total abajo
774
+ var tot=document.getElementById("totalSeg");
775
+ if(!tot){
776
+ tot=document.createElement("div");
777
+ tot.id="totalSeg";
778
+ tot.style.cssText="background:#1565c0;color:#fff;padding:10px;border-radius:8px;margin-top:10px;font-weight:700;text-align:center";
779
+ var cont=document.getElementById("escenas");
780
+ if(cont){cont.appendChild(tot);}
781
+ }
782
+ tot.textContent="Total del video: "+sumaSeg+" segundos (voz: "+Math.round(durVoz)+"s). Ajusta si quieres.";
783
+ }
784
+ var pollTimer=null;
785
+ function recolectarEscenas(){
786
+ var items=document.getElementsByClassName("escenaItem");
787
+ var lista=[];
788
+ for(var i=0;i<items.length;i++){
789
+ var sel=items[i].getElementsByClassName("selMaterial")[0];
790
+ var mat=sel?sel.value:"";
791
+ var seg=items[i].getElementsByClassName("segEscena")[0];
792
+ var segs=seg?parseInt(seg.value):5;
793
+ if(!segs||segs<1){segs=5;}
794
+ var txt=items[i].getElementsByClassName("txtEscena")[0];
795
+ var texto=txt?txt.value:"";
796
+ lista.push({material:mat,segundos:segs,texto:texto});
797
+ }
798
+ return lista;
799
+ }
800
+ async function genVideo(){
801
+ var m=document.getElementById("msgVideo");
802
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
803
+ // Lanzar el render sin esperar la respuesta (evita timeout)
804
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
805
+ guion:document.getElementById("guion").value,
806
+ sub_activo:document.getElementById("subActivo").value==="si",
807
+ sub_tamano:document.getElementById("subTamano").value,
808
+ sub_posicion:document.getElementById("subPosicion").value,
809
+ sub_color:document.getElementById("subColor").value,
810
+ efecto:document.getElementById("efecto").value,
811
+ escenas:recolectarEscenas()
812
+ })}).catch(e=>{});
813
+ // Comprobar cada 4 segundos si el video esta listo
814
+ var inicio=Date.now();
815
+ if(pollTimer)clearInterval(pollTimer);
816
+ pollTimer=setInterval(async()=>{
817
+ try{
818
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
819
+ if(d.listo && d.mtime*1000 > inicio){
820
+ clearInterval(pollTimer);
821
+ var u="/video?t="+Date.now();
822
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
823
+ m.innerHTML="<span class=\'tick\'>✓</span> Video listo! <a href=\'"+u+"\' target=\'_blank\' style=\'display:inline-block;background:#00E5FF;color:#000;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:800;margin-top:8px\'>▶ VER VIDEO</a>";
824
+ m.style.color="#00c853";
825
+ }
826
+ }catch(e){}
827
+ },4000);
828
+ }
829
+ async function subirMusica(){
830
+ var f=document.getElementById("musica").files[0]; if(!f)return;
831
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
832
+ var fd=new FormData(); fd.append("file",f);
833
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
834
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
835
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
836
+ }
837
+ function cargarVideo(){
838
+ var u="/video?t="+Date.now();
839
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
840
+ var m=document.getElementById("msgVideo");
841
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
842
+ m.style.color="#00c853";
843
+ }
844
+ async function nuevoVideo(){
845
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
846
+ try{
847
+ await fetch("/reset",{method:"POST"});
848
+ location.reload();
849
+ }catch(e){alert("Error al limpiar");}
850
+ }
851
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
852
+ function dividirEscenas(){
853
+ var t=document.getElementById("guion").value;
854
+ var sep=String.fromCharCode(10)+String.fromCharCode(10);
855
+ var bloques=t.split(sep);
856
+ var cont=document.getElementById("escenas");
857
+ var html="";
858
+ var n=0;
859
+ for(var i=0;i<bloques.length;i++){
860
+ var b=bloques[i].trim();
861
+ if(b==""){continue;}
862
+ n++;
863
+ var opciones="<option value=>-- material automatico --</option>";
864
+ for(var j=0;j<ARCHIVOS_SUBIDOS.length;j++){ opciones+="<option value="+ARCHIVOS_SUBIDOS[j]+">"+ARCHIVOS_SUBIDOS[j]+"</option>"; }
865
+ html+="<div class=escenaItem><b>Escena "+n+"</b><br><textarea class=txtEscena style=width:100%;min-height:50px;background:#111;color:#fff;border:1px solid #333;border-radius:6px;padding:6px>"+b+"</textarea><br><select class=selMaterial>"+opciones+"</select> <input type=number class=segEscena min=1 value=5 style=width:70px> seg</div>";
866
+ }
867
+ if(n==0){html="<div style=color:#888>Separa los parrafos con una linea en blanco</div>";}
868
+ cont.innerHTML=html;
869
+ }
870
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
871
+
872
+ function cambioVoz(){
873
+ var t=document.getElementById("tipoVoz").value;
874
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
875
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
876
+ }
877
+ var mediaRec=null, chunks=[], grabando=false;
878
+ async function toggleRec(){
879
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
880
+ if(!grabando){
881
+ try{
882
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
883
+ mediaRec=new MediaRecorder(stream); chunks=[];
884
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
885
+ mediaRec.onstop=async()=>{
886
+ var blob=new Blob(chunks,{type:"audio/webm"});
887
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
888
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
889
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
890
+ if(d.ok){var p=document.getElementById("player");p.src="/audio?t="+Date.now();p.style.display="block";m.textContent="✅ Tu voz lista, escuchala:";m.style.color="#00c853";}
891
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
892
+ };
893
+ mediaRec.start(); grabando=true;
894
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
895
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
896
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
897
+ }else{
898
+ mediaRec.stop(); grabando=false;
899
+ btn.textContent="Empezar a grabar"; btn.style.background="";
900
+ }
901
+ }
902
+ </script>
903
+ </body></html>"""
leonardo_service_backup.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import requests
4
+
5
+ _API_KEY = os.environ.get("LEONARDO_API_KEY", "").strip()
6
+ _BASE_URL = "https://cloud.leonardo.ai/api/rest/v1"
7
+ _WAIT = 300 # 5 minutos máximo esperando
8
+ _MAX_CLIPS = int(os.environ.get("VEO_MAX_CLIPS", "4"))
9
+
10
+ def _headers():
11
+ return {
12
+ "accept": "application/json",
13
+ "content-type": "application/json",
14
+ "authorization": f"Bearer {_API_KEY}"
15
+ }
16
+
17
+ def _verificar_saldo():
18
+ if not _API_KEY:
19
+ return False
20
+ try:
21
+ r = requests.get(f"{_BASE_URL}/me", headers=_headers())
22
+ return r.status_code not in (401, 402, 403, 429)
23
+ except:
24
+ return False
25
+
26
+ def _detectar_tema_groq(escenas):
27
+ try:
28
+ from groq import Groq
29
+ keys = [k for k in [os.environ.get("GROQ_KEY",""), os.environ.get("GROQ_KEY_2",""), os.environ.get("GROQ_KEY_3","")] if k]
30
+ if not keys: return ""
31
+ guion_completo = " ".join([e.get("texto", "") for e in escenas])
32
+ prompt_analisis = (
33
+ "Analyze this video script and output ONLY a short visual setting/theme descriptor in English "
34
+ "(e.g., 'modern corporate office finance', 'dark dramatic law courtroom', 'cosmic spiritual mystery'). "
35
+ f"Max 4 words, NO explanations, NO markdown, JUST the raw keywords:\n\n{guion_completo}"
36
+ )
37
+ for k in keys:
38
+ try:
39
+ client = Groq(api_key=k)
40
+ res = client.chat.completions.create(
41
+ messages=[{"role": "user", "content": prompt_analisis}],
42
+ model="llama-3.1-8b-instant",
43
+ temperature=0.2
44
+ )
45
+ return res.choices[0].message.content.strip().replace('"', '').replace('.', '')
46
+ except: continue
47
+ except: pass
48
+ return ""
49
+
50
+ def _crear_prompt_escena_groq(texto_escena, tema_global):
51
+ try:
52
+ from groq import Groq
53
+ keys = [k for k in [os.environ.get("GROQ_KEY",""), os.environ.get("GROQ_KEY_2",""), os.environ.get("GROQ_KEY_3","")] if k]
54
+ if not keys: return texto_escena
55
+
56
+ prompt_analisis = (
57
+ "You are a visual director. Convert this specific script line into a clear visual description in English for Leonardo AI.\n"
58
+ f"Global Cinematic Style: {tema_global}\n"
59
+ f"Script Line to represent: {texto_escena}\n\n"
60
+ "CRITICAL RULE: The description MUST literally show the concrete objects or actions mentioned. "
61
+ "Output ONLY the final raw English prompt descriptor, max 15 words, NO explanations, NO markdown."
62
+ )
63
+ for k in keys:
64
+ try:
65
+ client = Groq(api_key=k)
66
+ res = client.chat.completions.create(
67
+ messages=[{"role": "user", "content": prompt_analisis}],
68
+ model="llama-3.1-8b-instant",
69
+ temperature=0.3
70
+ )
71
+ return res.choices[0].message.content.strip().replace('"', '').replace('.', '')
72
+ except: continue
73
+ except: pass
74
+ return texto_escena
75
+
76
+ def _post_generar(prompt_visual):
77
+ url = f"{_BASE_URL}/generations-text-to-video"
78
+ payload = {
79
+ "prompt": prompt_visual + ", 4k resolution, cinematic lighting, ultra detailed, hyperrealistic, high contrast",
80
+ "model": "MOTION2FAST",
81
+ "width": 720,
82
+ "height": 1280
83
+ }
84
+ try:
85
+ r = requests.post(url, json=payload, headers=_headers(), timeout=60)
86
+ data = r.json()
87
+ if r.status_code >= 400:
88
+ print(f"[Leonardo Error] {data}", flush=True)
89
+ return None, "sin_saldo"
90
+ job_id = data.get("sdGenerationJob", {}).get("generationId")
91
+ return (job_id, "ok") if job_id else (None, "fallo")
92
+ except:
93
+ return None, "fallo"
94
+
95
+ def _poll_descargar(job_id, nombre):
96
+ url = f"{_BASE_URL}/generations/{job_id}"
97
+ t0 = time.time()
98
+ while time.time() - t0 < _WAIT:
99
+ time.sleep(15)
100
+ try:
101
+ r = requests.get(url, headers=_headers(), timeout=60)
102
+ data = r.json()
103
+ gens = data.get("generations_by_pk", {}).get("generated_images", [])
104
+ if gens:
105
+ # BLINDAJE: Solo coger el MP4 de movimiento, NADA de URLs alternativas ni fotos.
106
+ vid_url = gens[0].get("motionMP4URL")
107
+ if vid_url:
108
+ vid = requests.get(vid_url, timeout=180)
109
+ if vid.status_code == 200:
110
+ with open(nombre, "wb") as f:
111
+ f.write(vid.content)
112
+ return nombre
113
+ except:
114
+ pass
115
+ return None
116
+
117
+ def generar_clips_escenas(escenas):
118
+ if not _verificar_saldo(): return None
119
+
120
+ tema_global = _detectar_tema_groq(escenas)
121
+ if tema_global:
122
+ print(f"[Cerebro-Groq] Ambientacion visual global: {tema_global}", flush=True)
123
+
124
+ res = []
125
+ gen = 0
126
+ for i, e in enumerate(escenas):
127
+ txt = e.get("texto", "").strip()
128
+ mat = e.get("material", "").strip()
129
+ seg = int(e.get("segundos", 5) or 5)
130
+
131
+ if not mat and txt and gen < _MAX_CLIPS:
132
+ print(f"[Leonardo] Procesando escena {i+1}...", flush=True)
133
+
134
+ prompt_visual = _crear_prompt_escena_groq(txt, tema_global)
135
+ print(f"[Cerebro-Prompt Escena {i+1}]: {prompt_visual}", flush=True)
136
+
137
+ job_id, estado = _post_generar(prompt_visual)
138
+ if estado == "ok" and job_id:
139
+ clip = _poll_descargar(job_id, f"leonardo_clip_{i}.mp4")
140
+ if clip:
141
+ mat = clip
142
+ gen += 1
143
+ else:
144
+ return None
145
+
146
+ res.append({"material": mat, "segundos": seg, "texto": txt})
147
+ return res
ltx_service.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, shutil
2
+ _SPACE_ID = os.environ.get("LTX_SPACE_ID", "").strip()
3
+ _HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
4
+ _MAX_CLIPS = int(os.environ.get("VEO_MAX_CLIPS", "4"))
5
+ _WAIT_ARRANQUE = int(os.environ.get("LTX_WAIT", "900"))
6
+ if _SPACE_ID:
7
+ print(f"[LTX] Configurado | space={_SPACE_ID}", flush=True)
8
+ else:
9
+ print("[LTX] Falta LTX_SPACE_ID (se salta este motor)", flush=True)
10
+
11
+ def _crear_prompt_groq(texto):
12
+ try:
13
+ from groq import Groq
14
+ keys = [k for k in [os.environ.get("GROQ_KEY",""), os.environ.get("GROQ_KEY_2",""), os.environ.get("GROQ_KEY_3","")] if k]
15
+ if not keys: return texto
16
+ prompt = f"""Analyze this scene text and create a highly descriptive, 100% LITERAL visual prompt in English for an AI video generator.
17
+ Rules:
18
+ 1. Be literal to the objects, actions and niche of the text. ONLY use mystical elements if the text talks about them.
19
+ 2. Describe camera movement (slow zoom, pan, orbit) and lighting. NEVER include readable text, letters or logos.
20
+ 3. Output ONLY the prompt in English, max 30 words, no quotes, no markdown.
21
+ Scene text: {texto}"""
22
+ for k in keys:
23
+ try:
24
+ client = Groq(api_key=k)
25
+ res = client.chat.completions.create(messages=[{"role":"user","content":prompt}], model="llama-3.1-8b-instant", temperature=0.7)
26
+ return res.choices[0].message.content.strip().replace('"','').replace('.','')
27
+ except Exception: continue
28
+ except Exception: pass
29
+ return texto
30
+
31
+ def _conectar():
32
+ from gradio_client import Client
33
+ try:
34
+ return Client(_SPACE_ID, hf_token=_HF_TOKEN or None)
35
+ except Exception as e:
36
+ print(f"[LTX] Fabrica dormida ({e}). Despertando...", flush=True)
37
+ if not _HF_TOKEN:
38
+ print("[LTX] Sin HF_TOKEN no puedo despertarla.", flush=True)
39
+ return None
40
+ try:
41
+ from huggingface_hub import HfApi
42
+ HfApi(token=_HF_TOKEN).restart_space(_SPACE_ID)
43
+ except Exception as e:
44
+ print(f"[LTX] No pude reiniciarla: {e}", flush=True)
45
+ return None
46
+ t0 = time.time()
47
+ while time.time() - t0 < _WAIT_ARRANQUE:
48
+ time.sleep(30)
49
+ try:
50
+ from gradio_client import Client as C2
51
+ c = C2(_SPACE_ID, hf_token=_HF_TOKEN or None)
52
+ print(f"[LTX] Despierta en {time.time()-t0:.0f}s", flush=True)
53
+ return c
54
+ except Exception:
55
+ print("[LTX] Esperando arranque...", flush=True)
56
+ print("[LTX] Timeout de arranque.", flush=True)
57
+ return None
58
+
59
+ def generar_clips_escenas(escenas):
60
+ if not _SPACE_ID: return None
61
+ client = _conectar()
62
+ if not client: return None
63
+ res, gen = [], 0
64
+ for i, e in enumerate(escenas):
65
+ txt = e.get("texto","").strip()
66
+ mat = e.get("material","").strip()
67
+ seg = int(e.get("segundos",5) or 5)
68
+ if not mat and txt and gen < _MAX_CLIPS:
69
+ pv = _crear_prompt_groq(txt)
70
+ print(f"[LTX] Escena {i+1}: {pv}", flush=True)
71
+ try:
72
+ t = time.time()
73
+ out = client.predict(pv, seg, api_name="/generar")
74
+ ruta = out.get("video") if isinstance(out, dict) else out
75
+ if ruta and os.path.exists(ruta):
76
+ destino = f"ltx_clip_{i}.mp4"
77
+ shutil.copy(ruta, destino)
78
+ mat = destino
79
+ gen += 1
80
+ print(f"[LTX] Clip guardado: {destino} ({time.time()-t:.0f}s)", flush=True)
81
+ else:
82
+ print(f"[LTX] Sin video escena {i+1}: {out}", flush=True)
83
+ except Exception as ex:
84
+ print(f"[LTX] Error escena {i+1}: {ex}", flush=True)
85
+ res.append({"material": mat, "segundos": seg, "texto": txt})
86
+ return res if gen > 0 else None
parche_ltx.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil, time, sys
2
+ SELLO = time.strftime("%H%M")
3
+ shutil.copy("app.py", f"app_backup_antes_ltx_{SELLO}.py")
4
+ shutil.copy("requirements.txt", f"requirements_backup_{SELLO}.txt")
5
+ print(f"[OK] Backups creados (sello {SELLO})")
6
+ with open("app.py", "r", encoding="utf-8") as f:
7
+ codigo = f.read()
8
+ ANCLA_VIEJA = '''def generar_clips_escenas(escenas):
9
+ import copy
10
+ res = generar_runway(copy.deepcopy(escenas))
11
+ if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
12
+ print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
13
+ res_leo = generar_leonardo(copy.deepcopy(escenas))
14
+ return res_leo if res_leo else escenas'''
15
+ ANCLA_NUEVA = '''try:
16
+ from ltx_service import generar_clips_escenas as generar_ltx
17
+ except Exception:
18
+ def generar_ltx(escenas): return None
19
+ def generar_clips_escenas(escenas):
20
+ import copy
21
+ res = generar_ltx(copy.deepcopy(escenas))
22
+ if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res
23
+ print("[Cerebro] LTX no disponible. Saltando a Runway...", flush=True)
24
+ res = generar_runway(copy.deepcopy(escenas))
25
+ if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
26
+ print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
27
+ res_leo = generar_leonardo(copy.deepcopy(escenas))
28
+ return res_leo if res_leo else escenas'''
29
+ if ANCLA_NUEVA in codigo:
30
+ print("[AVISO] Parche ya aplicado.")
31
+ elif ANCLA_VIEJA not in codigo:
32
+ print("[ERROR] No encuentro la cascada. NO se toco nada.")
33
+ sys.exit(1)
34
+ else:
35
+ codigo = codigo.replace(ANCLA_VIEJA, ANCLA_NUEVA)
36
+ with open("app.py", "w", encoding="utf-8") as f:
37
+ f.write(codigo)
38
+ print("[OK] app.py parcheado: LTX -> Runway -> Leonardo")
39
+ reqs = open("requirements.txt", encoding="utf-8").read()
40
+ faltan = [p for p in ("gradio_client", "huggingface_hub") if p not in reqs]
41
+ if faltan:
42
+ with open("requirements.txt", "a", encoding="utf-8") as f:
43
+ if not reqs.endswith("\n"):
44
+ f.write("\n")
45
+ for p in faltan:
46
+ f.write(p + "\n")
47
+ print(f"[OK] Anadido: {', '.join(faltan)}")
48
+ import ast
49
+ ast.parse(open("app.py", encoding="utf-8").read())
50
+ print("[OK] app.py compila. Listo para git push")
requirements.txt CHANGED
@@ -18,3 +18,5 @@ google-auth
18
  google-auth-oauthlib
19
  google-genai
20
  opencv-python-headless
 
 
 
18
  google-auth-oauthlib
19
  google-genai
20
  opencv-python-headless
21
+ gradio_client
22
+ huggingface_hub
requirements_backup_1144.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ groq
5
+ edge-tts
6
+ moviepy==1.0.3
7
+ requests
8
+ httpx
9
+ pypdf
10
+ Pillow==9.5.0
11
+ numpy
12
+ imageio
13
+ imageio-ffmpeg
14
+ decorator
15
+ tqdm
16
+ google-api-python-client
17
+ google-auth
18
+ google-auth-oauthlib
19
+ google-genai
20
+ opencv-python-headless
veo_service.py.bak ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ veo_service.py — Video con RunwayML via RapidAPI (fortunehoppers / aivideoapi).
3
+ Plan gratis: 5 videos/mes. Texto -> video (Gen-3).
4
+ Flujo: POST /generate/text (devuelve uuid) -> GET /status?uuid=... (hasta listo) -> descarga.
5
+
6
+ Secrets en el Space:
7
+ RAPIDAPI_KEY (obligatorio) tu x-rapidapi-key
8
+ RAPIDAPI_HOST (opcional) def runwayml.p.rapidapi.com
9
+ RUNWAY_MODELO (opcional) def gen3
10
+ RUNWAY_GEN_PATH (opcional) def /generate/text
11
+ RUNWAY_STATUS_PATH (opcional) def /status
12
+ RUNWAY_TIME (opcional) segundos del clip, def 5
13
+ VEO_MAX_CLIPS (opcional) tope por video, def 4
14
+ """
15
+ import os
16
+ import time
17
+
18
+ _KEY = os.environ.get("RAPIDAPI_KEY", "").strip()
19
+ _HOST = os.environ.get("RAPIDAPI_HOST", "runwayml.p.rapidapi.com").strip()
20
+ _MODELO = os.environ.get("RUNWAY_MODELO", "gen3").strip()
21
+ _GEN_PATH = os.environ.get("RUNWAY_GEN_PATH", "/generate/text").strip()
22
+ _STATUS_PATH = os.environ.get("RUNWAY_STATUS_PATH", "/status").strip()
23
+ try:
24
+ _TIME = int(os.environ.get("RUNWAY_TIME", "5"))
25
+ except Exception:
26
+ _TIME = 5
27
+ try:
28
+ _WAIT = int(os.environ.get("RUNWAY_WAIT", "480")) # segundos max esperando a RunwayML antes de pasar a Pexels (480 = 8 min)
29
+ except Exception:
30
+ _WAIT = 120
31
+ try:
32
+ _MAX_CLIPS = int(os.environ.get("VEO_MAX_CLIPS", "4"))
33
+ except Exception:
34
+ _MAX_CLIPS = 4
35
+
36
+ _HEADERS = {
37
+ "x-rapidapi-key": _KEY,
38
+ "x-rapidapi-host": _HOST,
39
+ "Content-Type": "application/json",
40
+ }
41
+ _BASE = f"https://{_HOST}"
42
+
43
+ ready = bool(_KEY)
44
+ if ready:
45
+ print(f"[Veo] RunwayML(RapidAPI) OK | host={_HOST} | modelo={_MODELO} | max_clips={_MAX_CLIPS}", flush=True)
46
+ else:
47
+ print("[Veo] Falta RAPIDAPI_KEY", flush=True)
48
+
49
+
50
+ # Glosario tematico: si la narracion menciona estos terminos, se anade una pista visual
51
+ # (siempre siluetas / figuras de luz, NUNCA rostros detallados -> queda mejor y mas fiable)
52
+ _GLOSARIO = {
53
+ "teshua": "a luminous silhouette of a soul ascending back toward a radiant divine light source, soft golden rays",
54
+ "yeshua": "a glowing backlit silhouette of a robed figure with open arms, no detailed face, soft halo of light",
55
+ "jesus": "a glowing backlit silhouette of a robed figure with open arms, no detailed face, soft halo of light",
56
+ "cristo": "a glowing backlit silhouette of a robed figure, no detailed face, warm divine light behind",
57
+ "dios": "celestial light descending through clouds, divine radiance, soft god rays",
58
+ "padre": "vast warm light from above, gentle divine radiance through clouds",
59
+ "alma": "a glowing inner light, floating luminous particles, ethereal essence drifting upward",
60
+ "espiritu": "soft drifting light, mist and glowing particles floating in the air",
61
+ "luz": "warm radiant light flooding the scene, glowing ethereal atmosphere",
62
+ "fuente": "a radiant source of light glowing on the horizon, the origin of all light",
63
+ "angel": "a luminous winged silhouette backlit by soft light, no detailed face",
64
+ "cielo": "a vast glowing sky, soft luminous clouds, divine light breaking through",
65
+ "renacer": "dawn breaking, the first warm light rising over a horizon, sense of rebirth",
66
+ "renacimiento": "dawn light rising over a calm horizon, gentle rebirth, soft glow",
67
+ "camino": "a glowing path winding toward a distant radiant light",
68
+ "oracion": "a praying silhouette bathed in soft warm light, gentle glow",
69
+ "corazon": "a soft glowing light pulsing gently like a heartbeat, warm aura",
70
+ "eternidad": "an endless cosmos of soft stars and slow drifting light",
71
+ "agua": "calm reflective water shimmering with soft light, slow gentle ripples",
72
+ "desierto": "a vast serene desert under soft golden light, slow cinematic camera",
73
+ }
74
+
75
+
76
+ def _pistas_glosario(texto):
77
+ t = texto.lower()
78
+ pistas = []
79
+ for clave, pista in _GLOSARIO.items():
80
+ if clave in t and pista not in pistas:
81
+ pistas.append(pista)
82
+ return pistas[:3] # como mucho 3, para no saturar el prompt
83
+
84
+
85
+ def _texto_a_prompt_visual(narracion):
86
+ claves = [k for k in [os.environ.get("GROQ_KEY", ""),
87
+ os.environ.get("GROQ_KEY_2", ""),
88
+ os.environ.get("GROQ_KEY_3", "")] if k]
89
+ if not claves:
90
+ return narracion
91
+ pistas = _pistas_glosario(narracion)
92
+ extra = ""
93
+ if pistas:
94
+ extra = ("\n\nEste texto trata temas espirituales. Apoyate en simbolos visuales como: "
95
+ + "; ".join(pistas) + ".")
96
+ instr = (
97
+ "Eres director de fotografia de videos misticos y espirituales verticales (9:16).\n"
98
+ "Convierte la frase en una descripcion visual cinematografica breve "
99
+ "(maximo 30 palabras), escrita en INGLES.\n"
100
+ "Reglas:\n"
101
+ "- Traduce las ideas abstractas en SIMBOLOS visuales: luz, siluetas, naturaleza, "
102
+ "cosmos, amanecer, agua, caminos, umbrales.\n"
103
+ "- Para cualquier figura humana o divina usa SIEMPRE una silueta a contraluz o una "
104
+ "figura de luz, NUNCA un rostro detallado ni rasgos definidos.\n"
105
+ "- Atmosfera eterea, luz calida y suave, movimiento de camara lento y cinematografico.\n"
106
+ "- Nada de texto en pantalla ni logotipos.\n"
107
+ "- Responde SOLO la descripcion visual en ingles, sin comillas ni explicaciones."
108
+ + extra +
109
+ "\n\nFrase: " + narracion
110
+ )
111
+ try:
112
+ from groq import Groq
113
+ for k in claves:
114
+ try:
115
+ r = Groq(api_key=k).chat.completions.create(
116
+ messages=[{"role": "user", "content": instr}],
117
+ model="llama-3.1-8b-instant", temperature=0.85)
118
+ p = r.choices[0].message.content.strip().strip('"').strip()
119
+ if p:
120
+ return p[:400]
121
+ except Exception:
122
+ continue
123
+ except Exception:
124
+ pass
125
+ return narracion
126
+
127
+
128
+ def _buscar_url(obj):
129
+ """Busca recursivamente una URL de video en la respuesta."""
130
+ if isinstance(obj, str):
131
+ s = obj.lower()
132
+ if obj.startswith("http") and (".mp4" in s or "video" in s or "/files" in s):
133
+ return obj
134
+ return None
135
+ if isinstance(obj, dict):
136
+ for v in obj.values():
137
+ u = _buscar_url(v)
138
+ if u:
139
+ return u
140
+ if isinstance(obj, list):
141
+ for v in obj:
142
+ u = _buscar_url(v)
143
+ if u:
144
+ return u
145
+ return None
146
+
147
+
148
+ def _generar_un_clip(prompt, nombre):
149
+ if not ready:
150
+ print("[Veo] Sin RAPIDAPI_KEY, salto escena", flush=True)
151
+ return None
152
+ if os.path.exists(nombre) and os.path.getsize(nombre) > 1000:
153
+ print(f"[Veo] Reutilizando {nombre}", flush=True)
154
+ return nombre
155
+ try:
156
+ import requests
157
+ print(f"[Veo] Generando ({_MODELO}) {prompt[:70]}...", flush=True)
158
+ body = {
159
+ "text_prompt": prompt,
160
+ "model": _MODELO,
161
+ "width": 768,
162
+ "height": 1280,
163
+ "motion": 5,
164
+ "seed": 0,
165
+ "time": _TIME,
166
+ "callback_url": "",
167
+ }
168
+ r = requests.post(_BASE + _GEN_PATH, json=body, headers=_HEADERS, timeout=60)
169
+ try:
170
+ data = r.json()
171
+ except Exception:
172
+ print(f"[Veo] Respuesta no-JSON ({r.status_code}): {r.text[:200]}", flush=True)
173
+ return None
174
+ if r.status_code >= 400:
175
+ print(f"[Veo] Error {r.status_code} al generar: {str(data)[:200]}", flush=True)
176
+ return None
177
+ uuid = data.get("uuid") or data.get("task_id") or data.get("id")
178
+ if not uuid:
179
+ print(f"[Veo] No vino uuid: {str(data)[:200]}", flush=True)
180
+ return None
181
+ print(f"[Veo] En cola, uuid={uuid}. Esperando...", flush=True)
182
+
183
+ t0 = time.time()
184
+ intentos = 0
185
+ while time.time() - t0 < _WAIT:
186
+ time.sleep(12)
187
+ intentos += 1
188
+ try:
189
+ # El endpoint de estado es GET con el uuid como parametro
190
+ s = requests.get(_BASE + _STATUS_PATH, params={"uuid": uuid},
191
+ headers=_HEADERS, timeout=60)
192
+ sd = s.json()
193
+ except Exception as ex:
194
+ print(f"[Veo] estado intento {intentos}: fallo al consultar ({ex})", flush=True)
195
+ continue
196
+ # DIAGNOSTICO: ver que devuelve el endpoint de estado
197
+ if intentos <= 6:
198
+ print(f"[Veo] estado intento {intentos} (HTTP {s.status_code}): {str(sd)[:300]}", flush=True)
199
+ estado = str(sd.get("status", "")).lower()
200
+ url = _buscar_url(sd)
201
+ if url:
202
+ vid = requests.get(url, timeout=180)
203
+ if vid.status_code == 200 and len(vid.content) > 1000:
204
+ with open(nombre, "wb") as f:
205
+ f.write(vid.content)
206
+ print(f"[Veo] Guardado (bytes): {nombre}", flush=True)
207
+ return nombre
208
+ if estado in ("failed", "error", "rejected"):
209
+ print(f"[Veo] Falló: {str(sd)[:200]}", flush=True)
210
+ return None
211
+ print("[Veo] Timeout esperando el video", flush=True)
212
+ return None
213
+ except Exception as e:
214
+ print(f"[Veo] Error: {e}", flush=True)
215
+ return None
216
+
217
+
218
+ def generar_clips_escenas(escenas):
219
+ res = []
220
+ gen = 0
221
+ for i, e in enumerate(escenas):
222
+ mat = e.get("material", "").strip()
223
+ txt = e.get("texto", "").strip()
224
+ seg = int(e.get("segundos", 5) or 5)
225
+ if not mat and txt and ready and gen < _MAX_CLIPS:
226
+ clip = _generar_un_clip(_texto_a_prompt_visual(txt), f"veo_{i}.mp4")
227
+ if clip:
228
+ mat = clip
229
+ gen += 1
230
+ res.append({"material": mat, "segundos": seg, "texto": txt})
231
+ if gen >= _MAX_CLIPS:
232
+ print(f"[Veo] Tope de {_MAX_CLIPS} clips (resto usa tu material/Pexels)", flush=True)
233
+ return res