Cristobal299 commited on
Commit
614e552
·
1 Parent(s): 21a86fc

Agregar pestaña Libros analizados

Browse files
Files changed (1) hide show
  1. app.py +1103 -407
app.py CHANGED
@@ -1,425 +1,1121 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Centralita Torá HuggingFace Space (Gradio)
4
- Leer hebreo + español · escuchar (Canto Gregoriano-Hebraico con Voz Divina Especial)
5
- """
6
- import os, glob, json, tempfile, html, asyncio, subprocess, shutil, re
7
- import urllib.request
8
- import gradio as gr
9
- import edge_tts
10
  try:
11
- from gtts import gTTS
12
  except Exception:
13
- gTTS = None
14
-
15
- from gradio_theme_fenix import fenix_theme, FENIX_CSS
16
-
17
- # --- CONFIGURACIÓN DE VOZ Y CANTO ---
18
- CANTO_GREGORIANO_HEBRAICO = True
19
-
20
- VOZ_DIVINA = "es-ES-AlvaroNeural"
21
- VOZ_NARRADOR = "es-ES-AlvaroNeural" # Usamos Álvaro con procesamiento acústico sagrado especial
22
-
23
- VOZ_HE = "he-IL-AvriNeural"
24
-
25
- # Perillas de entonación para Narrador
26
- VOZ_RATE = "-10%"
27
- VOZ_PITCH = "+5Hz"
28
-
29
- # Fondo musical
30
- FONDO = "fondo.mp3"
31
- FONDO_VOL = 0.18
32
-
33
- # IA de Estudio (Groq)
34
- MODELO_ESTUDIA = "openai/gpt-oss-120b"
35
- CARPETA = "libros"
36
- GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
37
- GROQ_KEY = os.environ.get("GROQ_API_KEY")
38
-
39
-
40
- # --- MOTOR FONÉTICO GREGORIANO-HEBRAICO ---
41
- def transformar_a_canto_gregoriano_hebraico(texto_sagrado: str) -> str:
42
- if not texto_sagrado:
43
- return ""
44
-
45
- texto = texto_sagrado
46
-
47
- # Melismas gregorianos en vocales tónicas
48
- melismas = {
49
- 'á': 'a-a-a-a', 'é': 'e-e-e-e', 'í': 'i-i-i-i',
50
- 'ó': 'o-o-o-o', 'ú': 'u-u-u-u',
51
- 'Á': 'A-a-a-a', 'É': 'E-e-e-e', 'Í': 'I-i-i-i',
52
- 'Ó': 'O-o-o-o', 'Ú': 'U-u-u-u',
53
- }
54
- for vocal_con_tilde, melisma in melismas.items():
55
- texto = texto.replace(vocal_con_tilde, melisma)
56
-
57
- # Cierres litúrgicos
58
- def cierre_liturgico(match):
59
- vocal = match.group(1)
60
- puntuacion = match.group(2)
61
- return f"{vocal}-{vocal}-{vocal}{puntuacion} ... "
62
-
63
- texto = re.sub(r'([aeiouAEIOU])([,.:;])', cierre_liturgico, texto)
64
-
65
- # Énfasis místico
66
- texto = re.sub(r'\bDios\b', 'Di-o-o-os', texto, flags=re.IGNORECASE)
67
- texto = re.sub(r'\bSeñor\b', 'Se-ñoo-or', texto, flags=re.IGNORECASE)
68
- texto = re.sub(r'\bIsrael\b', 'Is-ra-e-el', texto, flags=re.IGNORECASE)
69
-
70
- return texto
71
-
72
-
73
- # --- PROCESAMIENTO ACÚSTICO FFMEPG ---
74
- def procesar_audio_narrador(ruta):
75
- """Efecto catedral estándar para el narrador."""
76
- if not shutil.which("ffmpeg"):
77
- return ruta
78
-
79
- filtros = [
80
- "atempo=0.22",
81
- "bass=g=8:f=133",
82
- "treble=g=-22",
83
- "vibrato=f=3.5:d=0.55",
84
- "aecho=0.75:0.70:120|240:0.4|0.25"
85
- ]
86
- salida = ruta[:-4] + "_narrador.mp3"
87
- cmd = ["ffmpeg", "-y", "-i", ruta, "-af", ", ".join(filtros), "-ac", "2", salida]
 
 
 
88
  try:
89
- subprocess.run(cmd, check=True, capture_output=True, timeout=60)
90
- if os.path.getsize(salida) > 0:
91
- return salida
92
- except Exception:
93
- pass
94
- return ruta
95
-
96
-
97
- def procesar_audio_divino(ruta):
98
- """
99
- Efecto ACÚSTICO SAGRADO Y PROFUNDO para la Voz de Dios.
100
- Tono más grave, resonancia sub-bass, eco más amplio y pureza vocal.
101
- """
102
- if not shutil.which("ffmpeg"):
103
- return ruta
104
-
105
- filtros = [
106
- "atempo=0.80", # Más pausado y solemne
107
- "asetrate=44100*0.45", # Bajar tono (más grave y imponente)
108
- "aresample=144000", # Reajustar sample rate
109
- "bass=g=15:f=200", # Graves profundos sub-bass
110
- "treble=g=-10", # Sonido cálido, sin aristas agudas
111
-
112
- ]
113
- salida = ruta[:-4] + "_divino.mp3"
114
- cmd = ["ffmpeg", "-y", "-i", ruta, "-af", ", ".join(filtros), "-ac", "2", salida]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  try:
116
- subprocess.run(cmd, check=True, capture_output=True, timeout=60)
117
- if os.path.getsize(salida) > 0:
118
- return salida
119
  except Exception:
120
  pass
121
- return ruta
122
-
123
-
124
- def concatenar_audios(lista_rutas):
125
- """Concatena varios archivos MP3 en uno solo en orden estricto."""
126
- if not lista_rutas:
127
- return None
128
- if len(lista_rutas) == 1:
129
- return lista_rutas[0]
130
-
131
- salida_final = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name
132
- list_file = tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False, encoding="utf-8")
133
-
134
- for r in lista_rutas:
135
- list_file.write(f"file '{os.path.abspath(r)}'\n")
136
- list_file.close()
137
-
138
- cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file.name, "-c", "copy", salida_final]
139
  try:
140
- subprocess.run(cmd, check=True, capture_output=True, timeout=120)
141
- os.remove(list_file.name)
142
- return salida_final
143
- except Exception:
144
- if os.path.exists(list_file.name):
145
- os.remove(list_file.name)
146
- return lista_rutas[0]
147
-
148
-
149
- def mezclar_fondo(voz_path):
150
- if not (os.path.exists(FONDO) and shutil.which("ffmpeg")):
151
- return voz_path
152
- salida = voz_path[:-4] + "_mix.mp3"
153
- filtro = (f"[1:a]volume={FONDO_VOL}[m];"
154
- f"[0:a][m]amix=inputs=2:duration=first:normalize=0[a]")
155
- cmd = ["ffmpeg", "-y", "-i", voz_path, "-stream_loop", "-1", "-i", FONDO,
156
- "-filter_complex", filtro, "-map", "[a]", salida]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  try:
158
- subprocess.run(cmd, check=True, capture_output=True, timeout=120)
159
- if os.path.getsize(salida) > 0:
160
- return salida
161
- except Exception:
162
- pass
163
- return voz_path
164
-
165
-
166
- # --- SEPARACIÓN DE BLOQUES (NARRADOR vs DIOS) ---
167
- def segmentar_texto_divino(texto):
168
- """
169
- Detecta patrones como 'Y dijo Dios: ...' o 'Dijo Dios ...'
170
- y divide el texto en partes ('narrador' o 'dios').
171
- """
172
- patron = r'((?:Y\s+dijo\s+Dios|dijo\s+Dios|Y\s+llamó\s+Dios|Y\s+bendijo\s+Dios)[^:,.–—]*[:,.–—]?\s*)([^.\n]+)'
173
- bloques = []
174
- ultimo_idx = 0
175
-
176
- for match in re.finditer(patron, texto, flags=re.IGNORECASE):
177
- start, end = match.span()
178
- if start > ultimo_idx:
179
- bloques.append(("narrador", texto[ultimo_idx:start]))
180
-
181
- intro_dios = match.group(1) # Ej: "Y dijo Dios: "
182
- palabras_dios = match.group(2) # Ej: "Sea la luz"
183
-
184
- bloques.append(("narrador", intro_dios))
185
- bloques.append(("dios", palabras_dios))
186
- ultimo_idx = end
187
-
188
- if ultimo_idx < len(texto):
189
- bloques.append(("narrador", texto[ultimo_idx:]))
190
-
191
- return bloques if bloques else [("narrador", texto)]
192
-
193
-
194
- # --- GENERACIÓN DE AUDIO DINÁMICA ---
195
- async def generar_bloque_audio(texto, es_divino):
196
- texto_tts = transformar_a_canto_gregoriano_hebraico(texto) if CANTO_GREGORIANO_HEBRAICO else texto
197
- ruta = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name
198
-
199
- voz = VOZ_DIVINA if es_divino else VOZ_NARRADOR
200
- pitch = "-5Hz" if es_divino else VOZ_PITCH
201
- rate = "-9%" if es_divino else VOZ_RATE
202
 
 
 
203
  try:
204
- com = edge_tts.Communicate(texto_tts[:4000], voz, rate=rate, pitch=pitch)
205
- await com.save(ruta)
206
- except Exception:
207
- return None
208
-
209
- if es_divino:
210
- return procesar_audio_divino(ruta)
211
- else:
212
- return procesar_audio_narrador(ruta)
213
-
214
-
215
- async def a_voz(texto, idioma):
216
- if not texto or not texto.strip():
217
- return None
218
-
219
- if idioma == "he":
220
- ruta = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name
221
- try:
222
- com = edge_tts.Communicate(texto[:4000], VOZ_HE, rate=VOZ_RATE, pitch=VOZ_PITCH)
223
- await com.save(ruta)
224
- return mezclar_fondo(ruta)
225
- except Exception:
226
- return None
227
-
228
- # Procesamiento para Español con Voz Divina Diferenciada
229
- bloques = segmentar_texto_divino(texto)
230
- audios_segmentos = []
231
-
232
- for tipo, contenido in bloques:
233
- if contenido.strip():
234
- es_divino = (tipo == "dios")
235
- audio_seg = await generar_bloque_audio(contenido, es_divino)
236
- if audio_seg:
237
- audios_segmentos.append(audio_seg)
238
-
239
- audio_final = concatenar_audios(audios_segmentos)
240
- if audio_final:
241
- audio_final = mezclar_fondo(audio_final)
242
-
243
- return audio_final
244
-
245
-
246
- async def leer_es(t):
247
- return await a_voz(t, "es")
248
-
249
-
250
- async def leer_he(t):
251
- return await a_voz(t, "he")
252
-
253
-
254
- # --- CARGA Y MANEJO DE LIBROS ---
255
- def cargar_libros():
256
- libros = {}
257
- for ruta in sorted(glob.glob(os.path.join(CARPETA, "*.json"))):
258
- try:
259
- with open(ruta, encoding="utf-8") as f:
260
- d = json.load(f)
261
- lid = d.get("id") or os.path.splitext(os.path.basename(ruta))[0]
262
- libros[lid] = d
263
- except Exception as e:
264
- print("No pude leer", ruta, e)
265
- return libros
266
-
267
-
268
- LIBROS = cargar_libros()
269
- ORDEN = ["Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy"]
270
- IDS = [i for i in ORDEN if i in LIBROS] + [i for i in LIBROS if i not in ORDEN]
271
- NOMBRES = [LIBROS[i].get("es", i) for i in IDS] or ["(sube tus libros)"]
272
-
273
 
274
- def id_por_nombre(nombre):
275
- for i in IDS:
276
- if LIBROS[i].get("es", i) == nombre:
277
- return i
278
- return IDS[0] if IDS else None
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
- def versiculos(lid, cap):
282
- return LIBROS.get(lid, {}).get("capitulos", {}).get(str(cap), [])
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
- def render(nombre, cap):
286
- lid = id_por_nombre(nombre)
287
- if not lid:
288
- return "<div class='pasaje'><p>Aún no has subido libros a la carpeta <b>libros/</b>.</p></div>", "", ""
289
  try:
290
- cap = max(1, int(float(cap or 1)))
291
- except Exception:
292
- cap = 1
293
- d = LIBROS[lid]
294
- vs = versiculos(lid, cap)
295
- if not vs:
296
- return f"<div class='pasaje'><p>No hay texto para {html.escape(d.get('es',''))} {cap}.</p></div>", "", ""
297
- filas = [f"<div class='cab'><span class='h'>{html.escape(d.get('heb',''))}</span><br>"
298
- f"{html.escape(d.get('es',''))} {cap}</div>"]
299
- plano_es, plano_he = [], []
300
- for v in vs:
301
- he = html.escape(v.get("he", "")); es = html.escape(v.get("es", ""))
302
- fila = f"<div class='verso'><span class='num'>{v.get('n','')}</span>"
303
- if he:
304
- fila += f"<span class='he'>{he}</span>"; plano_he.append(v.get("he", ""))
305
- if es:
306
- fila += f"<span class='es'>{es}</span>"; plano_es.append(v.get("es", ""))
307
- fila += "</div>"
308
- filas.append(fila)
309
- return f"<div class='pasaje'>{''.join(filas)}</div>", " ".join(plano_es), " ".join(plano_he)
310
-
311
-
312
- def estudiar(mensaje, historial, contexto):
313
- if not GROQ_KEY:
314
- return "Para el estudio, añade el Secret GROQ_API_KEY en el Space."
315
- sistema = (
316
- "Eres un compañero de estudio de la Torá que responde en español, cálido y honesto. "
317
- "Ofreces el sentido literal (peshat), contexto histórico y lingüístico, capas de la "
318
- "tradición (midrash, Rashi cuando venga al caso) y reflexión espiritual. Vas al grano.\n\n" + contexto
319
- )
320
- mensajes = [{"role": "system", "content": sistema}]
321
- for m in (historial or []):
322
- if isinstance(m, dict) and m.get("role") in ("user", "assistant"):
323
- mensajes.append({"role": m["role"], "content": m["content"]})
324
- mensajes.append({"role": "user", "content": mensaje})
325
- cuerpo = {"model": MODELO_ESTUDIA, "temperature": 0.4, "max_tokens": 1200, "messages": mensajes}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  try:
327
- data = json.dumps(cuerpo).encode("utf-8")
328
- req = urllib.request.Request(GROQ_URL, data=data, method="POST")
329
- req.add_header("Content-Type", "application/json")
330
- req.add_header("Authorization", f"Bearer {GROQ_KEY}")
331
- with urllib.request.urlopen(req, timeout=90) as r:
332
- d = json.load(r)
333
- return (d["choices"][0]["message"]["content"] or "").strip()
334
  except Exception as e:
335
- return f"No pude consultar a la IA ahora mismo ({e})."
336
-
337
-
338
- # ============================ INTERFAZ GRADIO ============================
339
- with gr.Blocks(theme=fenix_theme(), css=FENIX_CSS, title="Centralita Torá") as demo:
340
- st_es = gr.State("")
341
- st_he = gr.State("")
342
- st_nombre = gr.State(NOMBRES[0])
343
- st_cap = gr.State(1)
344
-
345
- gr.HTML("<div id='cabecera'><div class='estrella'>✡</div>"
346
- "<h1>Centralita Torá</h1><p>hebreo y español · canto Trope con voz celectial diferenciada</p></div>")
347
-
348
- with gr.Tab("Leer"):
349
- with gr.Row():
350
- dd = gr.Dropdown(NOMBRES, value=NOMBRES[0], label="Libro")
351
- ncap = gr.Number(value=1, precision=0, label="Capítulo", minimum=1)
352
- ver_btn = gr.Button("Ver capítulo", variant="primary")
353
- salida = gr.HTML()
354
- with gr.Row():
355
- b_es = gr.Button("🔊 Canto Trope + Voz celectial", variant="secondary")
356
- b_he = gr.Button("🔊 עברית (Hebreo)", variant="secondary")
357
- audio = gr.Audio(label="Audio Litúrgico", autoplay=True)
358
-
359
- def ver(nombre, cap):
360
- md, es, he = render(nombre, cap)
361
- return md, es, he, nombre, cap
362
-
363
- ver_btn.click(ver, [dd, ncap], [salida, st_es, st_he, st_nombre, st_cap])
364
- b_es.click(leer_es, st_es, audio)
365
- b_he.click(leer_he, st_he, audio)
366
-
367
- with gr.Tab("Buscar"):
368
- q = gr.Textbox(label="Palabra o frase (hebreo o español)", placeholder="ej. luz / אור")
369
- q_btn = gr.Button("Buscar", variant="primary")
370
- q_out = gr.HTML()
371
-
372
- def buscar(texto):
373
- t = (texto or "").strip().lower()
374
- if not t:
375
- return "<p>Escribe algo para buscar.</p>"
376
- filas = []
377
- for lid in IDS:
378
- d = LIBROS[lid]
379
- for c, vs in d.get("capitulos", {}).items():
380
- for v in vs:
381
- campos = " ".join(filter(None, [v.get("es"), v.get("he")])).lower()
382
- if t in campos:
383
- ref = f"{d.get('es', lid)} {c}:{v.get('n')}"
384
- txt = html.escape(v.get("es") or v.get("he") or "")
385
- filas.append(f"<div class='resultado'><span class='ref'>{ref}</span> — {txt}</div>")
386
- if len(filas) >= 120:
387
- return "".join(filas)
388
- return "".join(filas) if filas else "<p>Sin resultados en los libros cargados.</p>"
389
-
390
- q_btn.click(buscar, q, q_out)
391
-
392
- with gr.Tab("Estudiar"):
393
- gr.Markdown("La IA usa el capítulo abierto en **Leer** como contexto.")
394
- chat = gr.Chatbot(type="messages", height=360)
395
- pin = gr.Textbox(placeholder="Pregunta sobre el pasaje…", label="")
396
- with gr.Row():
397
- enviar = gr.Button("Preguntar", variant="primary")
398
- comentar = gr.Button("Comentar este capítulo", variant="secondary")
399
-
400
- def responder(mensaje, historial, es, he, nombre, cap):
401
- mensaje = (mensaje or "").strip()
402
- if not mensaje:
403
- return historial, ""
404
- contexto = f"Pasaje en pantalla — {nombre} {cap}:\nHebreo: {he}\nEspañol: {es}"
405
- r = estudiar(mensaje, historial, contexto)
406
- historial = (historial or []) + [
407
- {"role": "user", "content": mensaje},
408
- {"role": "assistant", "content": r},
409
- ]
410
- return historial, ""
411
-
412
- enviar.click(responder, [pin, chat, st_es, st_he, st_nombre, st_cap], [chat, pin])
413
- comentar.click(
414
- lambda h, es, he, n, c: responder("Comenta y ayúdame a estudiar este capítulo.", h, es, he, n, c),
415
- [chat, st_es, st_he, st_nombre, st_cap], [chat, pin],
416
- )
417
-
418
- with gr.Tab("Cómo subir"):
419
- gr.Markdown(
420
- "Sube un JSON por libro a la carpeta `libros/`. Para el estudio, añade el Secret `GROQ_API_KEY`."
421
- )
422
-
423
- if __name__ == "__main__":
424
- demo.launch(ssr_mode=False)
425
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ 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, nicho="espiritualidad", motor="auto"):
21
+ import copy
22
+ if motor == "ltx":
23
+ res = generar_ltx(copy.deepcopy(escenas), nicho=nicho)
24
+ if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res
25
+ print("[Cerebro] LTX elegido pero fallo.", flush=True); return escenas
26
+ if motor == "veo":
27
+ res = generar_runway(copy.deepcopy(escenas))
28
+ if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
29
+ print("[Cerebro] Veo elegido pero fallo.", flush=True); return escenas
30
+ if motor == "leonardo":
31
+ res_leo = generar_leonardo(copy.deepcopy(escenas))
32
+ return res_leo if res_leo else escenas
33
+ res = generar_ltx(copy.deepcopy(escenas), nicho=nicho)
34
+ if res and any(str(e.get("material", "")).startswith("ltx_") for e in res): return res
35
+ print("[Cerebro] LTX no disponible. Saltando a Runway...", flush=True)
36
+ res = generar_runway(copy.deepcopy(escenas))
37
+ if res and any(str(e.get("material", "")).startswith("veo_") for e in res): return res
38
+ print("[Cerebro] Saltando a Leonardo.ai...", flush=True)
39
+ res_leo = generar_leonardo(copy.deepcopy(escenas))
40
+ return res_leo if res_leo else escenas
41
+
42
+ app = FastAPI()
43
+
44
+ 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]
45
+
46
+ CLAVE_ACCESO = os.environ.get("CLAVE_ACCESO", "demo2026")
47
+
48
+ def verificar_clave(clave):
49
+ return clave == CLAVE_ACCESO
50
+
51
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
52
+
53
+ # Estado de la sesion (guarda guion editado y material entre pasos)
54
+ ESTADO = {
55
+ "guion": "",
56
+ "archivos": [],
57
+ "voz_lista": False,
58
+ "video_listo": False,
59
+ }
60
+
61
+ ESTILOS = {
62
+ "narrativo": "narrativo y emocional, como contando una historia",
63
+ "educativo": "educativo y claro, explicando con autoridad",
64
+ "motivador": "motivador e inspirador, que mueva a la accion",
65
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
66
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
67
+ }
68
+
69
+ VOCES = {
70
+ "es": {
71
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
72
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
73
+ },
74
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
75
+ }
76
+
77
+ def log(m): print(m, flush=True)
78
+
79
+ # Detecta la marca "Veo" en la esquina abajo-derecha y la tapa con el logo de Teshua.
80
+ # Si falla, devuelve el clip original (nunca rompe el montaje).
81
+ def quitar_marca_meta(path, _cache={}):
82
+ exts_v = (".mp4",".mov",".webm",".avi",".mkv")
83
+ exts_i = (".jpg",".jpeg",".png",".webp")
84
+ low = str(path).lower()
85
+ if not low.endswith(exts_v + exts_i):
86
+ return path
87
+ if path in _cache:
88
+ return _cache[path]
89
  try:
90
+ import subprocess
91
+ dims = subprocess.run(
92
+ ["ffprobe","-v","error","-select_streams","v:0",
93
+ "-show_entries","stream=width,height","-of","csv=p=0:s=x", path],
94
+ capture_output=True, text=True).stdout.strip()
95
+ W, H = [int(v) for v in dims.split("x")[:2]]
96
+ # Tapar marca "Veo" (banda negra abajo-derecha) con "Teshua" dorado
97
+ es_video = low.endswith(exts_v)
98
+ salida = path + ("_clean.mp4" if es_video else "_clean.png")
99
+ fs = max(22, int(H * 0.030))
100
+ pad = int(fs * 0.5)
101
+ # Caja: 22% ancho, 6% alto, pegada abajo-derecha con margen de 1%
102
+ bx = W - int(W * 0.23)
103
+ by = H - int(H * 0.065)
104
+ bw = int(W * 0.22)
105
+ bh = int(H * 0.060)
106
+ # Texto centrado dentro de la caja (coordenadas fijas, no variables ffmpeg)
107
+ tx = bx + int(bw * 0.08)
108
+ ty = by + int(bh * 0.20)
109
+ # 1) Caja negra que tapa "Veo" completamente
110
+ # 2) Borde dorado alrededor
111
+ # 3) Texto "Teshua" dorado encima, bien posicionado dentro de la caja
112
+ vf = (
113
+ f"drawbox=x={bx}:y={by}:w={bw}:h={bh}:color=black@1:t=fill,"
114
+ f"drawbox=x={bx}:y={by}:w={bw}:h={bh}:color=0xFFD700@1:t=2,"
115
+ f"drawtext=text='Teshua':fontsize={fs}:fontcolor=0xFFD700:x={tx}:y={ty}:font=serif:borderw=2:bordercolor=black"
116
+ )
117
+ cmd = ["ffmpeg","-y","-v","error","-i",path,"-vf",vf]
118
+ cmd += (["-c:a","copy",salida] if es_video else [salida])
119
+ result = subprocess.run(cmd, capture_output=True, text=True)
120
+ if result.returncode != 0:
121
+ log(f"marca ffmpeg error: {result.stderr[:300]}")
122
+ if os.path.exists(salida) and os.path.getsize(salida) > 0:
123
+ log(f"marca: {os.path.basename(path)} -> {os.path.basename(salida)} (logo={'si' if os.path.exists('logo.png') else 'no'})")
124
+ _cache[path] = salida; return salida
125
+ except Exception as e:
126
+ log(f"marca error: {e}")
127
+ _cache[path] = path
128
+ return path
129
+
130
+ # ========== GUION ==========
131
+ def generar_guion(texto_base, estilo, duracion=60):
132
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
133
+ palabras = int(duracion * 2.4)
134
+ last_err = None
135
+ MODELOS = ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b", "openai/gpt-oss-20b"]
136
+ 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."
137
+ # Probar cada modelo con cada key hasta que uno funcione
138
+ for modelo in MODELOS:
139
+ for k in GROQ_KEYS:
140
+ try:
141
+ client = Groq(api_key=k)
142
+ res = client.chat.completions.create(
143
+ messages=[{"role":"user","content":prompt}],
144
+ model=modelo, temperature=0.85
145
+ )
146
+ return res.choices[0].message.content.strip()
147
+ except Exception as e:
148
+ last_err = e
149
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
150
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
151
+ continue
152
+ raise last_err if last_err else Exception("Sin keys Groq")
153
+
154
+
155
+ def prompt_visual_auto(texto_escena):
156
+ if not texto_escena or not texto_escena.strip():
157
+ texto_escena = "cinematic atmospheric scene"
158
+ instruccion = (
159
+ "Eres un director de fotografia escribiendo el prompt para un modelo de video por IA (LTX). "
160
+ "Crea UN solo prompt en INGLES, fotorrealista y cinematografico, que ilustre la frase de abajo. "
161
+ "Escribelo como UNA descripcion fluida y en orden cronologico: "
162
+ "primero el plano y el encuadre, luego el sujeto y el entorno con detalle concreto, "
163
+ "luego la luz, el color y la atmosfera, y por ultimo UN movimiento de camara LENTO y SUAVE "
164
+ "(un push-in lento, una deriva suave, un tilt ascendente) mas algun movimiento de la escena "
165
+ "tambien lento y simple (la luz que cambia, particulas que flotan, niebla que se desplaza). "
166
+ "REGLA CLAVE para que el video no se deforme: el movimiento debe ser lento, continuo y simple; "
167
+ "NADA de movimiento rapido, accion compleja, ni personas con gestos detallados de manos o cara. "
168
+ "REGLA DE LUZ: la escena SIEMPRE claramente iluminada, con una fuente de luz visible "
169
+ "(luz de luna, horizonte que brilla, rayos de sol, estrellas, una vela); "
170
+ "NUNCA negro total ni infraexpuesto, el cuadro debe verse con claridad. "
171
+ "Entre 60 y 110 palabras, concreto, sin contradicciones, sin listas, sin numeros, sin comillas. "
172
+ "Solo el prompt en ingles. Frase: " + texto_escena)
173
+ for modelo in ["llama-3.1-8b-instant", "llama-3.3-70b-versatile"]:
174
+ for k in GROQ_KEYS:
175
+ try:
176
+ client = Groq(api_key=k)
177
+ res = client.chat.completions.create(
178
+ messages=[{"role":"user","content":instruccion}], model=modelo, temperature=0.8)
179
+ return res.choices[0].message.content.strip().strip('"')
180
+ except Exception as e:
181
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
182
+ if "model" in str(e).lower() or "decommission" in str(e).lower(): break
183
+ continue
184
+ return texto_escena
185
+
186
+ # ========== VOZ ==========
187
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
188
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
189
+ if pais:
190
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
191
+ cod = cods.get(pais.lower(),"")
192
+ if cod:
193
+ filt = [v for v in voces if f"-{cod}-" in v]
194
+ if filt: voces = filt
195
+ voz_sel = random.choice(voces)
196
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
197
+ return voz_sel
198
+
199
+ # ========== VIDEO ==========
200
+ def montar_video(guion, archivos, subs=None, efecto="ninguno", material_escenas=None, escenas_data=None):
201
+ subs = subs or {}
202
+ material_escenas = material_escenas or []
203
+ escenas_data = escenas_data or []
204
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
205
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
206
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
207
+
208
+ voz = AC("voz.mp3")
209
+ # Si el usuario asigno material por escena, usar ESE orden; si no, los archivos normales
210
+ if material_escenas:
211
+ fondos = [m + "_clean.mp4" if os.path.exists(m + "_clean.mp4") else m for m in material_escenas if os.path.exists(m) or os.path.exists(m + "_clean.mp4")]
212
+ else:
213
+ fondos = []
214
+ # Respaldo: si no hay fondos de escenas validos, usar el material general
215
+ if not fondos:
216
+ fondos = archivos[:8] if archivos else []
217
+
218
+ # Si no hay material propio, fondo de Pexels
219
+ raise Exception("[Fenix Error] Leonardo AI fallo o no tiene saldo, y Pexels esta desactivado.")
220
+
221
+ # === QUITAR MARCA "Meta AI" de cada clip de video antes de montar ===
222
+ fondos = [quitar_marca_meta(f) for f in fondos]
223
+ if material_escenas:
224
+ material_escenas = [quitar_marca_meta(m) if not str(m).endswith("_norm.mp4") else m for m in material_escenas]
225
+ if escenas_data:
226
+ escenas_data = [(quitar_marca_meta(m) if m and not str(m).endswith("_norm.mp4") else m, s) for m, s in escenas_data]
227
+
228
+ # Duracion: si hay escenas con segundos definidos, usar la SUMA; si no, la voz
229
+ if escenas_data:
230
+ suma_seg = sum(s for m,s in escenas_data)
231
+ dur_total = min(suma_seg, 1200) if suma_seg > 0 else min(voz.duration, 1200)
232
+ else:
233
+ dur_total = min(voz.duration, 1200)
234
+
235
+ # Si el material son SOLO videos, usar el video entero de fondo (no trocear)
236
+ solo_videos = fondos and all(str(f).lower().endswith((".mp4",".mov",".webm",".avi",".mkv")) for f in fondos)
237
+ if solo_videos:
238
+ from moviepy.editor import concatenate_videoclips as _ccv
239
+ vclips = []
240
+ for fp in fondos:
241
+ try:
242
+ vc = VC(fp).without_audio()
243
+ vc = vc.resize(height=1920) if vc.w/vc.h > 1080/1920 else vc.resize(width=1080)
244
+ vc = vc.crop(x_center=vc.w/2, y_center=vc.h/2, width=1080, height=1920)
245
+ vclips.append(vc)
246
+ except Exception as e:
247
+ log(f"Error video {fp}: {e}")
248
+ if vclips:
249
+ base = _ccv(vclips, method="compose") if len(vclips) > 1 else vclips[0]
250
+ # Repetir el video en bucle hasta cubrir toda la duracion (sin negro)
251
+ if base.duration < dur_total:
252
+ from moviepy.editor import concatenate_videoclips as _ccloop
253
+ repes = []
254
+ acumulado = 0
255
+ while acumulado < dur_total:
256
+ repes.append(base)
257
+ acumulado += base.duration
258
+ base = _ccloop(repes, method="compose").subclip(0, dur_total)
259
+ else:
260
+ base = base.subclip(0, dur_total)
261
+ clip = base
262
+ voz = voz.subclip(0, min(dur_total, voz.duration))
263
+ _USAR_VIDEO_ENTERO = True
264
+ else:
265
+ _USAR_VIDEO_ENTERO = False
266
+ else:
267
+ _USAR_VIDEO_ENTERO = False
268
+
269
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
270
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
271
+ dur_clip = dur_total / num_planos
272
+ # Si hay escenas con segundos, cada clip dura SUS segundos
273
+ # Solo usar segundos por escena si hay material asignado en las escenas
274
+ material_en_escenas = [(m,s) for m,s in escenas_data if m and os.path.exists(m)]
275
+ usar_seg_escena = bool(material_en_escenas) and not _USAR_VIDEO_ENTERO
276
+ if usar_seg_escena:
277
+ fondos_loop = [m for m,s in material_en_escenas]
278
+ segundos_loop = [s for m,s in material_en_escenas]
279
+ clips = []
280
+ idx_clip = -1
281
+ # EFECTO RAFAGA: las imagenes pasan rapido (0.6s) y se repiten en bucle hasta llenar la voz
282
+ _es_rafaga = (efecto == "rafaga") and not _USAR_VIDEO_ENTERO
283
+ if _es_rafaga and fondos_loop:
284
+ dur_rafaga = 0.2
285
+ n_necesarias = int(dur_total / dur_rafaga) + 1
286
+ fondos_loop = [fondos_loop[k % len(fondos_loop)] for k in range(n_necesarias)]
287
+ segundos_loop = [dur_rafaga for _ in fondos_loop]
288
+ usar_seg_escena = True
289
+ for fp in (fondos_loop if not _USAR_VIDEO_ENTERO else []):
290
+ idx_clip += 1
291
+ d_este = segundos_loop[idx_clip] if usar_seg_escena and idx_clip < len(segundos_loop) else dur_clip
292
+ try:
293
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
294
+ img = PILImage.open(fp).convert("RGB")
295
+ w, h = img.size
296
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
297
+ ratio = min(1080/w, 1920/h)
298
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
299
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
300
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
301
+ ic = IC(fp+"_r.jpg").set_duration(d_este)
302
+ if efecto in ("zoom","zoom_fundido"):
303
+ ic = ic.resize(lambda t: 1 + 0.015*t).set_duration(d_este)
304
+ ic = ic.set_duration(d_este)
305
+ clips.append(ic)
306
+ else:
307
+ vclip = VC(fp).without_audio()
308
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
309
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
310
+ vclip = vclip.loop(duration=d_este).set_duration(d_este)
311
+ clips.append(vclip)
312
+ except Exception as e:
313
+ log(f"Error clip {fp}: {e}")
314
+
315
+ if not _USAR_VIDEO_ENTERO:
316
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
317
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
318
+ from moviepy.editor import concatenate_videoclips as _cc
319
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
320
+ clip = _cc(clips_fx, method="compose", padding=-0.5)
321
+ else:
322
+ clip = concatenate_videoclips(clips, method="compose")
323
+ # MANUAL (material asignado a escenas): forzar duracion = suma de segundos exacta
324
+ _es_manual = bool([m for m,s in escenas_data if m and os.path.exists(m)]) if escenas_data else False
325
+ if _es_manual:
326
+ suma = sum(s for m,s in escenas_data if m and os.path.exists(m))
327
+ if clip.duration < suma:
328
+ # Congelar el ultimo fotograma hasta llegar a la duracion (NO dejar negro)
329
+ from moviepy.editor import ImageClip as _ICf
330
+ ultimo = clip.to_ImageClip(t=clip.duration-0.1, duration=suma-clip.duration)
331
+ from moviepy.editor import concatenate_videoclips as _ccf
332
+ clip = _ccf([clip, ultimo.set_pos("center")], method="compose")
333
+ else:
334
+ clip = clip.subclip(0, suma)
335
+ dur_total = suma
336
+ else:
337
+ # AUTOMATICO: dura lo que suman los clips (va de lujo, no tocar)
338
+ dur_total = clip.duration
339
+ voz = voz.subclip(0, min(dur_total, voz.duration))
340
+
341
+ import re as _re
342
+ sub_color = subs.get("color","#FFFFFF")
343
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
344
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
345
+ sub_activo = subs.get("activo",True)
346
+
347
+ # --- SUBTITULOS SINCRONIZADOS POR FRASE ---
348
+ # 1) Cortar respetando frases reales: puntuacion (. ! ? :) y saltos de linea
349
+ crudo = [s.strip() for s in _re.split(r'(?<=[\.\!\?\:])\s+|\n+', guion) if s.strip()]
350
+ # 2) Trocear solo las frases largas (max ~7 palabras) SIN mezclar frases distintas
351
+ segmentos = []
352
+ for fr in crudo:
353
+ pal = fr.split()
354
+ if len(pal) <= 8:
355
+ segmentos.append(fr)
356
+ else:
357
+ for i in range(0, len(pal), 7):
358
+ segmentos.append(" ".join(pal[i:i+7]))
359
+ if not segmentos:
360
+ segmentos = [guion]
361
+ # 3) Repartir el tiempo PROPORCIONAL a las palabras de cada frase (mejor sincronia)
362
+ total_pal = sum(max(len(s.split()), 1) for s in segmentos)
363
+ dur_voz = voz.duration
364
+ txts = []
365
+ if sub_activo:
366
+ t_cursor = 0.0
367
+ for s in segmentos:
368
+ d = dur_voz * (max(len(s.split()), 1) / total_pal)
369
+ txts.append(
370
+ TextClip(s.upper(), fontsize=sub_size, color=sub_color,
371
+ font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
372
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
373
+ ).set_start(t_cursor).set_duration(d).set_pos(("center", sub_pos), relative=True)
374
+ )
375
+ t_cursor += d
376
+
377
+ # Mezclar musica de fondo si el cliente la subio
378
+ import os as _os
379
+ audio_final = voz
380
+ if _os.path.exists("musica.mp3"):
381
+ try:
382
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
383
+ musica = _AC("musica.mp3").volumex(0.18)
384
+ dur = voz.duration
385
+ if musica.duration > dur:
386
+ musica = musica.subclip(0, dur)
387
+ elif musica.duration < dur:
388
+ import math
389
+ reps = math.ceil(dur / musica.duration)
390
+ from moviepy.editor import concatenate_audioclips
391
+ musica = concatenate_audioclips([_AC("musica.mp3").volumex(0.18) for _ in range(reps)]).subclip(0, dur)
392
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
393
+ except Exception as _em:
394
+ log(f"Error mezcla musica: {_em}")
395
+ audio_final = voz
396
+ _vid = CompositeVideoClip([clip]+txts)
397
+ try:
398
+ _vd = getattr(_vid, "duration", None)
399
+ _ad = getattr(audio_final, "duration", None)
400
+ if _vd and _ad:
401
+ if _ad > _vd + 0.05:
402
+ # audio mas largo que el video -> recortar
403
+ audio_final = audio_final.subclip(0, _vd)
404
+ log(f"[Video] audio recortado a {_vd:.1f}s (era {_ad:.1f}s)")
405
+ elif _vd > _ad:
406
+ # video mas largo que el audio -> rellenar con silencio hasta el final
407
+ from moviepy.editor import CompositeAudioClip as _CAC
408
+ audio_final = _CAC([audio_final]).set_duration(_vd)
409
+ log(f"[Video] audio rellenado con silencio a {_vd:.1f}s (era {_ad:.1f}s)")
410
+ # alinear exacto para que ffmpeg nunca lea fuera de rango
411
+ audio_final = audio_final.set_duration(_vd)
412
+ except Exception as _ea:
413
+ log(f"[Video] ajuste audio: {_ea}")
414
+ final = _vid.set_audio(audio_final)
415
  try:
416
+ if getattr(_vid, "duration", None):
417
+ final = final.set_duration(_vid.duration)
 
418
  except Exception:
419
  pass
420
+ import os as _os2
421
+ for _f in ("preview.mp4", "preview_tmp.mp4"):
422
+ if _os2.path.exists(_f):
423
+ _os2.remove(_f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  try:
425
+ final.write_videofile("preview_tmp.mp4", fps=24, codec="libx264", preset="fast", logger=None, threads=4, audio_codec="aac", ffmpeg_params=["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
426
+ except Exception as _ew:
427
+ log(f"[Video] write_videofile FALLO: {_ew}")
428
+ raise
429
+ if not _os2.path.exists("preview_tmp.mp4"):
430
+ raise RuntimeError("write_videofile no genero preview_tmp.mp4 (revisa audio/duracion)")
431
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
432
+ return "preview.mp4"
433
+
434
+ # ========== ENDPOINTS ==========
435
+ @app.post("/login")
436
+ async def ep_login(request: Request):
437
+ d = await request.json()
438
+ clave = d.get("clave","").strip()
439
+ if verificar_clave(clave):
440
+ return {"ok": True}
441
+ return JSONResponse({"error":"Clave incorrecta"}, status_code=401)
442
+
443
+ @app.post("/generar-guion")
444
+ async def ep_guion(request: Request):
445
+ d = await request.json()
446
+ texto = d.get("texto","").strip()
447
+ estilo = d.get("estilo","narrativo")
448
+ duracion = int(d.get("duracion", 60) or 60)
449
+ parte = d.get("parte","auto")
450
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
451
+ completo = ESTADO.get("texto_completo","")
452
+ if completo and len(completo) > 6000:
453
+ if parte == "principio":
454
+ texto = completo[:6000]
455
+ elif parte == "mitad":
456
+ m = len(completo)//2
457
+ texto = completo[m-3000:m+3000]
458
+ elif parte == "final":
459
+ texto = completo[-6000:]
460
+ else: # auto = trozo al azar
461
+ import random as _r
462
+ ini = _r.randint(0, max(0, len(completo)-6000))
463
+ texto = completo[ini:ini+6000]
464
+ if not texto:
465
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
466
  try:
467
+ guion = generar_guion(texto, estilo, duracion)
468
+ ESTADO["guion"] = guion
469
+ return {"guion": guion}
470
+ except Exception as e:
471
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
 
473
+ @app.post("/subir-material")
474
+ async def ep_material(file: UploadFile = File(...)):
475
  try:
476
+ import subprocess as _sp, os as _os
477
+ ext = file.filename.split(".")[-1].lower()
478
+ idx = len(ESTADO['archivos'])
479
+ if ext in ("mp4","mov","webm","avi","mkv","m4v"):
480
+ crudo = f"crudo_{idx}.{ext}"
481
+ with open(crudo,"wb") as f:
482
+ f.write(await file.read())
483
+ nombre = f"media_{idx}.mp4"
484
+ # Re-encodear a h264 estandar y fps fijo (arregla grabaciones de pantalla y formatos raros)
485
+ r = _sp.run(["ffmpeg","-y","-i",crudo,"-r","30","-c:v","libx264","-preset","ultrafast","-pix_fmt","yuv420p","-an",nombre], capture_output=True)
486
+ if _os.path.exists(nombre) and _os.path.getsize(nombre) > 1000:
487
+ try: _os.remove(crudo)
488
+ except: pass
489
+ else:
490
+ nombre = crudo
491
+ else:
492
+ nombre = f"media_{idx}.{ext}"
493
+ with open(nombre,"wb") as f:
494
+ f.write(await file.read())
495
+ ESTADO["archivos"].append(nombre)
496
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
497
+ except Exception as e:
498
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
499
+
500
+ @app.post("/generar-voz")
501
+ async def ep_voz(request: Request):
502
+ d = await request.json()
503
+ guion = d.get("guion","").strip()
504
+ if not guion:
505
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
506
+ ESTADO["guion"] = guion
507
+ try:
508
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
509
+ ESTADO["voz_lista"] = True
510
+ return {"ok": True}
511
+ except Exception as e:
512
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
 
 
 
 
 
 
514
 
515
+ @app.post("/subir-voz-grabada")
516
+ async def ep_voz_grabada(file: UploadFile = File(...)):
517
+ try:
518
+ import subprocess
519
+ contenido = await file.read()
520
+ with open("voz_grabada_raw","wb") as f:
521
+ f.write(contenido)
522
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
523
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
524
+ import os as _os
525
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
526
+ ESTADO["voz_lista"] = True
527
+ return {"ok": True}
528
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
529
+ except Exception as e:
530
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
531
 
 
 
532
 
533
+ @app.post("/subir-musica")
534
+ async def ep_musica(file: UploadFile = File(...)):
535
+ try:
536
+ import subprocess, os as _os
537
+ contenido = await file.read()
538
+ with open("musica_raw","wb") as f:
539
+ f.write(contenido)
540
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
541
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
542
+ ESTADO["musica"] = True
543
+ return {"ok": True}
544
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
545
+ except Exception as e:
546
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
547
 
548
+ @app.post("/generar-musica-ia")
549
+ async def ep_musica_ia(request: Request):
 
 
550
  try:
551
+ d = await request.json()
552
+ nicho = d.get("nicho", "espiritual")
553
+ duracion = int(d.get("duracion", 20))
554
+ url_fabrica = os.environ.get("URL_FABRICA_MUSICA", "").rstrip("/")
555
+ if not url_fabrica:
556
+ return JSONResponse({"error": "Secret URL_FABRICA_MUSICA no configurado"}, status_code=500)
557
+ PROMPTS = {
558
+ "espiritual": "mystical ambient music, spiritual journey, ethereal choir, no lyrics",
559
+ "numerologia": "meditative orchestral music, sacred geometry, soft piano and strings",
560
+ "motivacional": "epic cinematic music, uplifting, orchestral, no vocals",
561
+ "terror": "dark atmospheric tension, horror underscore, eerie strings",
562
+ "naturaleza": "peaceful nature sounds, light acoustic guitar, calm",
563
+ "default": "cinematic background music, neutral mood, no vocals",
564
+ }
565
+ prompt = next((v for k, v in PROMPTS.items() if k in nicho.lower()), PROMPTS["default"])
566
+ from gradio_client import Client
567
+ client = Client(url_fabrica)
568
+ ruta = client.predict(prompt, duracion, api_name="/generar_musica")
569
+ if isinstance(ruta, (list, tuple)) and ruta:
570
+ ruta = ruta[0]
571
+ if isinstance(ruta, dict):
572
+ ruta = ruta.get("path") or ruta.get("name") or ""
573
+ if not ruta or not os.path.exists(ruta):
574
+ log(f"[Musica] sin fichero valido: {ruta}")
575
+ return JSONResponse({"error": "La Fabrica no devolvio audio"}, status_code=500)
576
+ with open(ruta, "rb") as fi, open("musica.mp3", "wb") as fo:
577
+ fo.write(fi.read())
578
+ ESTADO["musica"] = True
579
+ log(f"[Musica] OK -> {ruta}")
580
+ return {"ok": True}
581
+ except Exception as e:
582
+ log(f"[Musica] ERROR: {e}")
583
+ return JSONResponse({"error": str(e)[:200]}, status_code=500)
584
+
585
+ @app.get("/musica-lista")
586
+ def ep_musica_lista():
587
+ if os.path.exists("musica.mp3"):
588
+ return {"listo": True, "mtime": os.path.getmtime("musica.mp3")}
589
+ return {"listo": False, "mtime": 0}
590
+
591
+
592
+ @app.get("/audio")
593
+ def ep_audio():
594
+ if os.path.exists("voz.mp3"):
595
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
596
+ return JSONResponse({"error":"sin audio"}, status_code=404)
597
+
598
+ @app.post("/generar-video")
599
+ async def ep_video(request: Request):
600
+ d = await request.json()
601
+ guion = d.get("guion","").strip() or ESTADO["guion"]
602
+ if not os.path.exists("voz.mp3"):
603
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
604
+ subs = {
605
+ "color": d.get("sub_color","#FFFFFF"),
606
+ "tamano": d.get("sub_tamano","mediano"),
607
+ "posicion": d.get("sub_posicion","abajo"),
608
+ "activo": d.get("sub_activo", True),
609
+ }
610
+ efecto = d.get("efecto","ninguno")
611
+ escenas = d.get("escenas", [])
612
+ # Modo híbrido: rellenar escenas vacías con clips de Veo
613
+ if ESTADO["archivos"]:
614
+ # Si hay archivos subidos, los asignamos a las escenas automáticas
615
+ for idx, e in enumerate(escenas):
616
+ mat = str(e.get("material", "")).strip()
617
+ if not mat or mat == "automático" or mat == "automatico":
618
+ # Usamos tus archivos de forma circular si hay menos que escenas
619
+ e["material"] = ESTADO["archivos"][idx % len(ESTADO["archivos"])]
620
+ if not ESTADO["archivos"]:
621
+ from nichos import detectar_nicho
622
+ nicho_detectado = detectar_nicho(guion)
623
+ print(f"[Fenix] Nicho detectado: {nicho_detectado}", flush=True)
624
+ for e in escenas:
625
+ if not e.get("prompt","").strip():
626
+ e["prompt"] = prompt_visual_auto(e.get("texto","") or e.get("narracion",""))
627
+ print("[Fenix] Prompt auto generado", flush=True)
628
+ for e in escenas:
629
+ if e.get("prompt","").strip() and not e.get("prompt_manual","").strip():
630
+ e["prompt_manual"] = e["prompt"].strip()
631
+ motor = d.get("motor","auto")
632
+ escenas = generar_clips_escenas(escenas, nicho=nicho_detectado, motor=motor)
633
+ # Lista de (material, segundos) de cada escena, en orden
634
+ # Pasar el prompt visual del usuario a cada escena para que los servicios lo usen
635
+ for e in escenas:
636
+ if e.get("prompt","").strip() and not e.get("prompt_manual","").strip():
637
+ e["prompt_manual"] = e["prompt"].strip()
638
+ escenas_data = [(e.get("material",""), int(e.get("segundos",5) or 5)) for e in escenas]
639
+ material_escenas = [m for m,s in escenas_data if m]
640
  try:
641
+ loop = asyncio.get_event_loop()
642
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto, material_escenas, escenas_data)
643
+ ESTADO["video_listo"] = True
644
+ return {"ok": True}
 
 
 
645
  except Exception as e:
646
+ import traceback
647
+ tb = traceback.format_exc()
648
+ print("ERROR VIDEO:", tb, flush=True)
649
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
650
+
651
+
652
+ @app.get("/video-listo")
653
+ def ep_video_listo():
654
+ import os as _os
655
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
656
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
657
+ return {"listo": False}
658
+
659
+ @app.get("/video")
660
+ def ep_video_file():
661
+ if os.path.exists("preview.mp4"):
662
+ return FileResponse("preview.mp4", media_type="video/mp4")
663
+ return JSONResponse({"error":"sin video"}, status_code=404)
664
+
665
+ @app.post("/reset")
666
+ def ep_reset():
667
+ for f in glob.glob("media_*")+glob.glob("fondo*")+glob.glob("leonardo_*")+["voz.mp3","preview.mp4","musica.mp3","musica_raw"]:
668
+ try: os.remove(f)
669
+ except: pass
670
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
671
+ return {"ok": True}
672
+
673
+ @app.post("/subir-pdf")
674
+ async def ep_pdf(file: UploadFile = File(...)):
675
+ try:
676
+ contenido = await file.read()
677
+ with open("temp.pdf","wb") as f:
678
+ f.write(contenido)
679
+ from pypdf import PdfReader
680
+ reader = PdfReader("temp.pdf")
681
+ texto = ""
682
+ for pagina in reader.pages:
683
+ texto += (pagina.extract_text() or "") + "\n"
684
+ texto = texto.strip()
685
+ if not texto:
686
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
687
+ ESTADO["texto_completo"] = texto
688
+ # Mostrar preview de los primeros 6000 al usuario
689
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
690
+ except Exception as e:
691
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
692
+
693
+ # ========== INTERFAZ ==========
694
+ @app.get("/", response_class=HTMLResponse)
695
+ def home():
696
+ return """<!DOCTYPE html>
697
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
698
+ <title>Fenix Hybrid Engine</title>
699
+ <style>
700
+ *{box-sizing:border-box;margin:0;padding:0}
701
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
702
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
703
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
704
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
705
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
706
+ 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}
707
+ textarea{min-height:90px;resize:vertical}
708
+ 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}
709
+ button:disabled{opacity:.4}
710
+ .btn2{background:#222;color:#fff;border:1px solid #444}
711
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
712
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
713
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
714
+ .row{display:flex;gap:8px}.row>*{flex:1}
715
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
716
+
717
+ .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}
718
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
719
+ .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}
720
+ </style></head><body>
721
+ <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">
722
+ <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>
723
+ <p style="color:#888;margin-bottom:24px;font-size:.95rem">Introduce tu clave de acceso</p>
724
+ <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()">
725
+ <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>
726
+ <div id="loginMsg" style="color:#ff5252;margin-top:12px;font-size:.9rem;min-height:18px"></div>
727
+ </div>
728
+ <script>
729
+ async function hacerLogin(){
730
+ var clave=document.getElementById("claveInput").value;
731
+ var msg=document.getElementById("loginMsg");
732
+ msg.textContent="Comprobando...";msg.style.color="#ffb700";
733
+ try{
734
+ var r=await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clave:clave})});
735
+ var d=await r.json();
736
+ if(d.ok){document.getElementById("loginOverlay").style.display="none";}
737
+ else{msg.textContent="Clave incorrecta";msg.style.color="#ff5252";}
738
+ }catch(e){msg.textContent="Error";msg.style.color="#ff5252";}
739
+ }
740
+ </script>
741
+
742
+ <h1>Fenix Hybrid Engine</h1>
743
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
744
+ <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>
745
+ <div id="panelAyuda" style="display:none;background:#1a1a1a;border:1px solid #ff6b00;border-radius:10px;padding:16px;margin:10px 0;line-height:1.6">
746
+ <b>COMO HACER TU VIDEO (paso a paso):</b><br><br>
747
+ <b>1.</b> Sube un PDF o pega tu texto.<br>
748
+ <b>2.</b> Elige la duracion (15s, 30s, 45s, 1 o 2 min) y el estilo. Pulsa Generar Guion.<br>
749
+ <b>3.</b> Si quieres, edita el guion a tu gusto.<br>
750
+ <b>4.</b> Pulsa Dividir en escenas.<br>
751
+ <b>5.</b> Sube tus imagenes o videos.<br>
752
+ <b>6.</b> Genera la voz. Los segundos de cada escena se ponen SOLOS segun la voz, y abajo veras el total.<br>
753
+ <b>7.</b> Elige color de subtitulos y efecto si quieres.<br>
754
+ <b>8.</b> Pulsa Generar Video y descargalo. Sale perfecto, sin pantalla negra.<br><br>
755
+ <b>DOS MODOS DE MONTAR:</b><br>
756
+ - <b>AUTOMATICO (facil):</b> deja el material en automatico. El sistema reparte las imagenes solo. Ideal para ir rapido.<br>
757
+ - <b>MANUAL (control total):</b> asigna una imagen a cada escena. Tu decides que imagen va en cada parte.<br><br>
758
+ <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>
759
+ </div>
760
+
761
+ <div class="fase">
762
+ <div class="fase-t">1. Ingesta de conocimiento</div>
763
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
764
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
765
+ <div class="msg" id="msgPdf"></div>
766
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
767
+ <span class="lbl">Duracion del video:</span>
768
+ <select id="duracion">
769
+ <option value="15">15 segundos</option>
770
+ <option value="30">30 segundos</option>
771
+ <option value="45">45 segundos</option>
772
+ <option value="60">1 minuto</option>
773
+ <option value="120">2 minutos</option>
774
+ </select>
775
+ <span class="lbl">Estilo de narrativa:</span>
776
+ <select id="estilo">
777
+ <label style="display:block;margin:10px 0 4px">Motor de video:</label>
778
+ <option value="narrativo">Narrativo y emocional</option>
779
+ <option value="educativo">Educativo y claro</option>
780
+ <option value="motivador">Motivador e inspirador</option>
781
+ <option value="publicitario">Publicitario y persuasivo</option>
782
+ <option value="misterio">Misterio e intriga</option>
783
+ </select>
784
+ <span class="lbl">Motor de video:</span>
785
+ <select id="motorVideo">
786
+ <option value="auto">Automatico (LTX - Veo - Leonardo)</option>
787
+ <option value="ltx">Solo LTX (gratis, mi GPU)</option>
788
+ <option value="veo">Solo Veo (Google, calidad alta)</option>
789
+ <option value="leonardo">Solo Leonardo</option>
790
+ </select>
791
+
792
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
793
+ <select id="parte">
794
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
795
+ <option value="principio">Principio del documento</option>
796
+ <option value="mitad">Mitad del documento</option>
797
+ <option value="final">Final del documento</option>
798
+ </select>
799
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
800
+ <div class="msg" id="msgGuion"></div>
801
+ </div>
802
+
803
+ <div class="fase">
804
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
805
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
806
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
807
+ <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>
808
+ <div id="escenas" style="margin-top:12px"></div>
809
+ </div>
810
+
811
+ <div class="fase">
812
+ <div class="fase-t">3. Tu material</div>
813
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
814
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
815
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
816
+ </div>
817
+
818
+ <div class="fase">
819
+ <div class="fase-t">4. Voz</div>
820
+ <select id="tipoVoz" onchange="cambioVoz()">
821
+ <option value="ia">Voz IA (automatica)</option>
822
+ <option value="grabada">Mi voz grabada (con microfono)</option>
823
+ </select>
824
+ <div id="vozIA">
825
+ <div class="row">
826
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
827
+ <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>
828
+ </div>
829
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
830
+ </div>
831
+ <div id="vozGrabada" style="display:none">
832
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
833
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
834
+ </div>
835
+ <div class="msg" id="msgVoz"></div>
836
+ <audio id="player" controls style="display:none"></audio>
837
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
838
+ <span class="lbl">Musica de fondo:</span>
839
+ <div style="display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap">
840
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:10px 14px;background:#1a1a1a;border-radius:8px;border:2px solid #333;flex:1" onclick="setModoMusica(\'no\')">
841
+ <input type="radio" name="modoMusica" value="no" checked style="width:auto;margin:0"> <span>Sin musica</span>
842
+ </label>
843
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:10px 14px;background:#1a1a1a;border-radius:8px;border:2px solid #333;flex:1" onclick="setModoMusica(\'ia\')">
844
+ <input type="radio" name="modoMusica" value="ia" style="width:auto;margin:0"> <span>🎵 Musica IA</span>
845
+ </label>
846
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:10px 14px;background:#1a1a1a;border-radius:8px;border:2px solid #333;flex:1" onclick="setModoMusica(\'subir\')">
847
+ <input type="radio" name="modoMusica" value="subir" style="width:auto;margin:0"> <span>📁 Subir mi musica</span>
848
+ </label>
849
+ </div>
850
+ <div id="panelMusicaIA" style="display:none">
851
+ <select id="nichoMusica" style="margin-bottom:8px">
852
+ <option value="espiritual">Espiritual / Meditacion</option>
853
+ <option value="motivacional">Motivacional / Epico</option>
854
+ <option value="numerologia">Numerologia / Mistico</option>
855
+ <option value="terror">Tension / Terror</option>
856
+ <option value="naturaleza">Naturaleza / Calma</option>
857
+ </select>
858
+ <button class="btn2" onclick="generarMusicaIA()">🎼 Generar Musica con IA</button>
859
+ </div>
860
+ <div id="panelMusicaSubir" style="display:none">
861
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
862
+ </div>
863
+ <div class="msg" id="msgMusica"></div>
864
+ </div>
865
+ </div>
866
+
867
+ <div class="fase">
868
+ <div class="fase-t">5. Subtitulos y Montaje</div>
869
+ <div class="row">
870
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
871
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
872
+ </div>
873
+ <div class="row">
874
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
875
+ <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>
876
+ </div>
877
+ <span class="lbl">Efecto de video:</span>
878
+ <select id="efecto">
879
+ <option value="ninguno">Sin efecto (estatico)</option>
880
+ <option value="zoom">Zoom lento (Ken Burns)</option>
881
+ <option value="fundido">Fundido suave entre planos</option>
882
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
883
+ <option value="rafaga">Rafaga rapida (videoclip)</option>
884
+ </select>
885
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
886
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
887
+ <div class="msg" id="msgVideo"></div>
888
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
889
+ </div>
890
+
891
+ <div class="fase">
892
+ <div class="fase-t">6. Publicacion</div>
893
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
894
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">Nuevo Video (limpiar todo)</button>
895
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
896
+ <div class="msg" id="msgPub"></div>
897
+ </div>
898
+
899
+ <script>
900
+ var ARCHIVOS_SUBIDOS=[];
901
+ async function subirPdf(){
902
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
903
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
904
+ var fd=new FormData(); fd.append("file",f);
905
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
906
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
907
+ else{m.textContent=d.error;m.style.color="#ff5252";}
908
+ }
909
+ async function genGuion(){
910
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
911
+ 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})});
912
+ var d=await r.json();
913
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
914
+ else{m.textContent=d.error;m.style.color="#ff5252";}
915
+ }
916
+ async function subirMaterial(){
917
+ var files=document.getElementById("material").files; if(!files.length)return;
918
+ var m=document.getElementById("msgMaterial");
919
+ var total=0;
920
+ for(var i=0;i<files.length;i++){
921
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
922
+ var fd=new FormData(); fd.append("file",files[i]);
923
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
924
+ if(d.ok){total=d.total; if(d.nombre){ARCHIVOS_SUBIDOS.push(d.nombre);}}
925
+ }
926
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
927
+ }
928
+ async function genVoz(){
929
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
930
+ var guionFinal=document.getElementById("guion").value;
931
+ var esc=recolectarEscenas();
932
+ if(esc.length>0){
933
+ var textos=[];
934
+ for(var k=0;k<esc.length;k++){ if(esc[k].texto){textos.push(esc[k].texto);} }
935
+ if(textos.length>0){guionFinal=textos.join(". ");}
936
+ }
937
+ 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})});
938
+ var d=await r.json();
939
+ 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";
940
+ p.onloadedmetadata=function(){ sugerirSegundos(p.duration); };
941
+ }
942
+ else{m.textContent=d.error;m.style.color="#ff5252";}
943
+ }
944
+ function sugerirSegundos(durVoz){
945
+ if(!durVoz||durVoz<1){return;}
946
+ var items=document.getElementsByClassName("escenaItem");
947
+ if(items.length==0){return;}
948
+ // Contar palabras de cada escena y el total
949
+ var palabrasEsc=[]; var total=0;
950
+ for(var i=0;i<items.length;i++){
951
+ var t=items[i].getElementsByClassName("txtEscena")[0];
952
+ var np=t?t.value.trim().split(" ").filter(function(x){return x.length>0;}).length:1;
953
+ palabrasEsc.push(np); total+=np;
954
+ }
955
+ // Repartir la duracion de la voz segun palabras y rellenar
956
+ var sumaSeg=0;
957
+ for(var i=0;i<items.length;i++){
958
+ var seg=Math.max(1, Math.round(durVoz*(palabrasEsc[i]/total)));
959
+
960
+ sumaSeg+=seg;
961
+ var campo=items[i].getElementsByClassName("segEscena")[0];
962
+ if(campo){campo.value=seg;}
963
+ }
964
+ // Mostrar total abajo
965
+ var tot=document.getElementById("totalSeg");
966
+ if(!tot){
967
+ tot=document.createElement("div");
968
+ tot.id="totalSeg";
969
+ tot.style.cssText="background:#1565c0;color:#fff;padding:10px;border-radius:8px;margin-top:10px;font-weight:700;text-align:center";
970
+ var cont=document.getElementById("escenas");
971
+ if(cont){cont.appendChild(tot);}
972
+ }
973
+ tot.textContent="Total del video: "+sumaSeg+" segundos (voz: "+Math.round(durVoz)+"s). Ajusta si quieres.";
974
+ }
975
+ var pollTimer=null;
976
+ function recolectarEscenas(){
977
+ var items=document.getElementsByClassName("escenaItem");
978
+ var lista=[];
979
+ for(var i=0;i<items.length;i++){
980
+ var sel=items[i].getElementsByClassName("selMaterial")[0];
981
+ var mat=sel?sel.value:"";
982
+ var seg=items[i].getElementsByClassName("segEscena")[0];
983
+ var segs=seg?parseInt(seg.value):5;
984
+ if(!segs||segs<1){segs=5;}
985
+ var txt=items[i].getElementsByClassName("txtEscena")[0];
986
+ var texto=txt?txt.value:"";
987
+ var prEl=items[i].getElementsByClassName("promptEscena")[0];
988
+ var prompt_escena=prEl?prEl.value:"";
989
+ lista.push({material:mat,segundos:segs,texto:texto,prompt:prompt_escena});
990
+ }
991
+ return lista;
992
+ }
993
+ async function genVideo(){
994
+ var m=document.getElementById("msgVideo");
995
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
996
+ // Ocultar y vaciar el reproductor viejo: evita dar a play sobre un preview ya borrado
997
+ var _vold=document.getElementById("vid"); if(_vold){ _vold.pause(); _vold.removeAttribute("src"); _vold.load(); _vold.style.display="none"; }
998
+ // Lanzar el render sin esperar la respuesta (evita timeout)
999
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
1000
+ guion:document.getElementById("guion").value,
1001
+ sub_activo:document.getElementById("subActivo").value==="si",
1002
+ sub_tamano:document.getElementById("subTamano").value,
1003
+ sub_posicion:document.getElementById("subPosicion").value,
1004
+ sub_color:document.getElementById("subColor").value,
1005
+ efecto:document.getElementById("efecto").value,
1006
+ motor:document.getElementById("motorVideo").value,
1007
+ escenas:recolectarEscenas()
1008
+ })}).catch(e=>{});
1009
+ // Comprobar cada 4 segundos si el video esta listo
1010
+ var inicio=Date.now();
1011
+ if(pollTimer)clearInterval(pollTimer);
1012
+ pollTimer=setInterval(async()=>{
1013
+ try{
1014
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
1015
+ if(d.listo && d.mtime*1000 > inicio){
1016
+ clearInterval(pollTimer);
1017
+ var u="/video?t="+Date.now();
1018
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
1019
+ 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>";
1020
+ m.style.color="#00c853";
1021
+ }
1022
+ }catch(e){}
1023
+ },4000);
1024
+ }
1025
+ async function subirMusica(){
1026
+ var f=document.getElementById("musica").files[0]; if(!f)return;
1027
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
1028
+ var fd=new FormData(); fd.append("file",f);
1029
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
1030
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
1031
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
1032
+ }
1033
+ function cargarVideo(){
1034
+ var u="/video?t="+Date.now();
1035
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
1036
+ var m=document.getElementById("msgVideo");
1037
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
1038
+ m.style.color="#00c853";
1039
+ }
1040
+ async function nuevoVideo(){
1041
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
1042
+ try{
1043
+ await fetch("/reset",{method:"POST"});
1044
+ location.reload();
1045
+ }catch(e){alert("Error al limpiar");}
1046
+ }
1047
+ function setModoMusica(modo){
1048
+ document.getElementById("panelMusicaIA").style.display=modo==="ia"?"block":"none";
1049
+ document.getElementById("panelMusicaSubir").style.display=modo==="subir"?"block":"none";
1050
+ }
1051
+ async function generarMusicaIA(){
1052
+ var m=document.getElementById("msgMusica");
1053
+ m.innerHTML="<span class='spinner'></span> Generando musica con IA (puede tardar 1-2 min)...";m.style.color="#ffb700";
1054
+ var nicho=document.getElementById("nichoMusica").value;
1055
+ var dur=parseInt(document.getElementById("duracion").value||"20")+5;
1056
+ var inicioMus=Date.now();
1057
+ fetch("/generar-musica-ia",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({nicho:nicho,duracion:dur})}).catch(function(e){});
1058
+ if(window.musicaTimer)clearInterval(window.musicaTimer);
1059
+ window.musicaTimer=setInterval(async()=>{
1060
+ try{
1061
+ var r=await fetch("/musica-lista?t="+Date.now());var d=await r.json();
1062
+ if(d.listo && d.mtime*1000 > inicioMus){
1063
+ clearInterval(window.musicaTimer);
1064
+ m.textContent="✅ Musica IA lista, sonara de fondo";m.style.color="#00c853";
1065
+ }
1066
+ }catch(e){}
1067
+ },4000);
1068
+ }
1069
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
1070
+ function dividirEscenas(){
1071
+ var t=document.getElementById("guion").value;
1072
+ var sep=String.fromCharCode(10)+String.fromCharCode(10);
1073
+ var bloques=t.split(sep);
1074
+ var cont=document.getElementById("escenas");
1075
+ var html="";
1076
+ var n=0;
1077
+ for(var i=0;i<bloques.length;i++){
1078
+ var b=bloques[i].trim();
1079
+ if(b==""){continue;}
1080
+ n++;
1081
+ var opciones="<option value=>-- material automatico --</option>";
1082
+ for(var j=0;j<ARCHIVOS_SUBIDOS.length;j++){ opciones+="<option value="+ARCHIVOS_SUBIDOS[j]+">"+ARCHIVOS_SUBIDOS[j]+"</option>"; }
1083
+ html+="<div class=escenaItem style='background:#111;border:1px solid #222;border-radius:10px;padding:12px;margin-bottom:10px'><b>Escena "+n+"</b><br><textarea class=txtEscena style='width:100%;min-height:50px;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:6px;padding:6px;margin-bottom:6px'>"+b+"</textarea><select class=selMaterial style='width:100%;margin-bottom:6px'>"+opciones+"</select><div style='display:flex;align-items:center;gap:8px;margin-bottom:6px'><input type=number class=segEscena min=1 value=5 style='width:70px;background:#0a0a0a;color:#fff;border:1px solid #333;border-radius:6px;padding:6px'> <span style='color:#aaa;font-size:.85rem'>seg</span></div><textarea class=promptEscena placeholder='Prompt visual para esta escena (opcional)...' style='width:100%;min-height:40px;background:#0a0a0a;color:#fff;border:1px solid #ff6b00;border-radius:6px;padding:6px;font-size:.85rem'></textarea></div>";
1084
+ }
1085
+ if(n==0){html="<div style=color:#888>Separa los parrafos con una linea en blanco</div>";}
1086
+ cont.innerHTML=html;
1087
+ }
1088
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
1089
+
1090
+ function cambioVoz(){
1091
+ var t=document.getElementById("tipoVoz").value;
1092
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
1093
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
1094
+ }
1095
+ var mediaRec=null, chunks=[], grabando=false;
1096
+ async function toggleRec(){
1097
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
1098
+ if(!grabando){
1099
+ try{
1100
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
1101
+ mediaRec=new MediaRecorder(stream); chunks=[];
1102
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
1103
+ mediaRec.onstop=async()=>{
1104
+ var blob=new Blob(chunks,{type:"audio/webm"});
1105
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
1106
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
1107
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
1108
+ 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";}
1109
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
1110
+ };
1111
+ mediaRec.start(); grabando=true;
1112
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
1113
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
1114
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
1115
+ }else{
1116
+ mediaRec.stop(); grabando=false;
1117
+ btn.textContent="Empezar a grabar"; btn.style.background="";
1118
+ }
1119
+ }
1120
+ </script>
1121
+ </body></html>"""