Cristobal299 commited on
Commit
019a024
·
2 Parent(s): af7f8b23f3e21f

Merge branch 'main' of https://huggingface.co/spaces/Cristobal299/pdf-reader

Browse files
Files changed (1) hide show
  1. app.py +447 -1098
app.py CHANGED
@@ -1,1121 +1,470 @@
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>"""
 
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
+ # Nombres de libros en español, para mostrar lindo en el desplegable
256
+ # aunque el archivo se llame en inglés (Genesis.json, Exodus.json, etc.)
257
+ NOMBRES_ES = {
258
+ "Genesis": "Génesis", "Exodus": "Éxodo", "Leviticus": "Levítico",
259
+ "Numbers": "Números", "Deuteronomy": "Deuteronomio", "Joshua": "Josué",
260
+ "Judges": "Jueces", "Ruth": "Rut", "1_Samuel": "1 Samuel", "2_Samuel": "2 Samuel",
261
+ "1_Kings": "1 Reyes", "2_Kings": "2 Reyes", "1_Chronicles": "1 Crónicas",
262
+ "2_Chronicles": "2 Crónicas", "Ezra": "Esdras", "Nehemiah": "Nehemías",
263
+ "Esther": "Ester", "Job": "Job", "Psalms": "Salmos", "Proverbs": "Proverbios",
264
+ "Ecclesiastes": "Eclesiastés", "Song_of_Songs": "Cantar de los Cantares",
265
+ "Isaiah": "Isaías", "Jeremiah": "Jeremías", "Lamentations": "Lamentaciones",
266
+ "Ezekiel": "Ezequiel", "Daniel": "Daniel", "Hosea": "Oseas", "Joel": "Joel",
267
+ "Amos": "Amós", "Obadiah": "Abdías", "Jonah": "Jonás", "Micah": "Miqueas",
268
+ "Nahum": "Nahúm", "Habakkuk": "Habacuc", "Zephaniah": "Sofonías",
269
+ "Haggai": "Hageo", "Zechariah": "Zacarías", "Malachi": "Malaquías",
270
+ }
271
 
272
+
273
+ def _convertir_traduccion_plana(book_id: str, data: dict) -> dict:
274
+ """
275
+ Convierte el formato plano que genera el traductor
276
+ ({"translation": "...", "missing_strong_numbers": [...]})
277
+ a la estructura de capítulos/versículos que usa la Centralita.
278
+ No tenemos separación real de capítulos en este formato, así que
279
+ todo el libro se muestra como Capítulo 1, con una línea = un versículo.
280
+ """
281
+ texto = data.get("translation", "") or ""
282
+ lineas = [l.strip() for l in texto.split("\n") if l.strip()]
283
+ versiculos = [{"n": i + 1, "he": "", "es": linea} for i, linea in enumerate(lineas)]
284
+ return {
285
+ "id": book_id,
286
+ "es": NOMBRES_ES.get(book_id, book_id),
287
+ "heb": "",
288
+ "capitulos": {"1": versiculos},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
+
291
+
292
+ def cargar_libros():
293
+ libros = {}
294
+ for ruta in sorted(glob.glob(os.path.join(CARPETA, "*.json"))):
295
+ try:
296
+ with open(ruta, encoding="utf-8") as f:
297
+ d = json.load(f)
298
+ book_id = os.path.splitext(os.path.basename(ruta))[0]
299
+ if "capitulos" in d:
300
+ # Formato completo ya listo (id/es/heb/capitulos)
301
+ lid = d.get("id") or book_id
302
+ libros[lid] = d
303
+ elif "translation" in d:
304
+ # Formato plano del traductor (translation + missing_strong_numbers)
305
+ libros[book_id] = _convertir_traduccion_plana(book_id, d)
306
+ else:
307
+ print(f"Formato no reconocido en {ruta}, se omite.")
308
+ except Exception as e:
309
+ print("No pude leer", ruta, e)
310
+ return libros
311
+
312
+
313
+ LIBROS = cargar_libros()
314
+ ORDEN = ["Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy"]
315
+ IDS = [i for i in ORDEN if i in LIBROS] + [i for i in LIBROS if i not in ORDEN]
316
+ NOMBRES = [LIBROS[i].get("es", i) for i in IDS] or ["(sube tus libros)"]
317
+
318
+
319
+ def id_por_nombre(nombre):
320
+ for i in IDS:
321
+ if LIBROS[i].get("es", i) == nombre:
322
+ return i
323
+ return IDS[0] if IDS else None
324
+
325
+
326
+ def versiculos(lid, cap):
327
+ return LIBROS.get(lid, {}).get("capitulos", {}).get(str(cap), [])
328
+
329
+
330
+ def render(nombre, cap):
331
+ lid = id_por_nombre(nombre)
332
+ if not lid:
333
+ return "<div class='pasaje'><p>Aún no has subido libros a la carpeta <b>libros/</b>.</p></div>", "", ""
334
  try:
335
+ cap = max(1, int(float(cap or 1)))
336
+ except Exception:
337
+ cap = 1
338
+ d = LIBROS[lid]
339
+ vs = versiculos(lid, cap)
340
+ if not vs:
341
+ return f"<div class='pasaje'><p>No hay texto para {html.escape(d.get('es',''))} {cap}.</p></div>", "", ""
342
+ filas = [f"<div class='cab'><span class='h'>{html.escape(d.get('heb',''))}</span><br>"
343
+ f"{html.escape(d.get('es',''))} {cap}</div>"]
344
+ plano_es, plano_he = [], []
345
+ for v in vs:
346
+ he = html.escape(v.get("he", "")); es = html.escape(v.get("es", ""))
347
+ fila = f"<div class='verso'><span class='num'>{v.get('n','')}</span>"
348
+ if he:
349
+ fila += f"<span class='he'>{he}</span>"; plano_he.append(v.get("he", ""))
350
+ if es:
351
+ fila += f"<span class='es'>{es}</span>"; plano_es.append(v.get("es", ""))
352
+ fila += "</div>"
353
+ filas.append(fila)
354
+ return f"<div class='pasaje'>{''.join(filas)}</div>", " ".join(plano_es), " ".join(plano_he)
355
+
356
+
357
+ def estudiar(mensaje, historial, contexto):
358
+ if not GROQ_KEY:
359
+ return "Para el estudio, añade el Secret GROQ_API_KEY en el Space."
360
+ sistema = (
361
+ "Eres un compañero de estudio de la Torá que responde en español, cálido y honesto. "
362
+ "Ofreces el sentido literal (peshat), contexto histórico y lingüístico, capas de la "
363
+ "tradición (midrash, Rashi cuando venga al caso) y reflexión espiritual. Vas al grano.\n\n" + contexto
364
+ )
365
+ mensajes = [{"role": "system", "content": sistema}]
366
+ for m in (historial or []):
367
+ if isinstance(m, dict) and m.get("role") in ("user", "assistant"):
368
+ mensajes.append({"role": m["role"], "content": m["content"]})
369
+ mensajes.append({"role": "user", "content": mensaje})
370
+ cuerpo = {"model": MODELO_ESTUDIA, "temperature": 0.4, "max_tokens": 1200, "messages": mensajes}
371
  try:
372
+ data = json.dumps(cuerpo).encode("utf-8")
373
+ req = urllib.request.Request(GROQ_URL, data=data, method="POST")
374
+ req.add_header("Content-Type", "application/json")
375
+ req.add_header("Authorization", f"Bearer {GROQ_KEY}")
376
+ with urllib.request.urlopen(req, timeout=90) as r:
377
+ d = json.load(r)
378
+ return (d["choices"][0]["message"]["content"] or "").strip()
 
 
 
 
 
 
 
379
  except Exception as e:
380
+ return f"No pude consultar a la IA ahora mismo ({e})."
381
+
382
+
383
+ # ============================ INTERFAZ GRADIO ============================
384
+ with gr.Blocks(theme=fenix_theme(), css=FENIX_CSS, title="Centralita Torá") as demo:
385
+ st_es = gr.State("")
386
+ st_he = gr.State("")
387
+ st_nombre = gr.State(NOMBRES[0])
388
+ st_cap = gr.State(1)
389
+
390
+ gr.HTML("<div id='cabecera'><div class='estrella'>✡</div>"
391
+ "<h1>Centralita Torá</h1><p>hebreo y español · canto Trope con voz celectial diferenciada</p></div>")
392
+
393
+ with gr.Tab("Leer"):
394
+ with gr.Row():
395
+ dd = gr.Dropdown(NOMBRES, value=NOMBRES[0], label="Libro")
396
+ ncap = gr.Number(value=1, precision=0, label="Capítulo", minimum=1)
397
+ ver_btn = gr.Button("Ver capítulo", variant="primary")
398
+ salida = gr.HTML()
399
+ with gr.Row():
400
+ b_es = gr.Button("🔊 Canto Trope + Voz celectial", variant="secondary")
401
+ b_he = gr.Button("🔊 עברית (Hebreo)", variant="secondary")
402
+ audio = gr.Audio(label="Audio Litúrgico", autoplay=True)
403
+
404
+ def ver(nombre, cap):
405
+ md, es, he = render(nombre, cap)
406
+ return md, es, he, nombre, cap
407
+
408
+ ver_btn.click(ver, [dd, ncap], [salida, st_es, st_he, st_nombre, st_cap])
409
+ b_es.click(leer_es, st_es, audio)
410
+ b_he.click(leer_he, st_he, audio)
411
+
412
+ with gr.Tab("Buscar"):
413
+ q = gr.Textbox(label="Palabra o frase (hebreo o español)", placeholder="ej. luz / אור")
414
+ q_btn = gr.Button("Buscar", variant="primary")
415
+ q_out = gr.HTML()
416
+
417
+ def buscar(texto):
418
+ t = (texto or "").strip().lower()
419
+ if not t:
420
+ return "<p>Escribe algo para buscar.</p>"
421
+ filas = []
422
+ for lid in IDS:
423
+ d = LIBROS[lid]
424
+ for c, vs in d.get("capitulos", {}).items():
425
+ for v in vs:
426
+ campos = " ".join(filter(None, [v.get("es"), v.get("he")])).lower()
427
+ if t in campos:
428
+ ref = f"{d.get('es', lid)} {c}:{v.get('n')}"
429
+ txt = html.escape(v.get("es") or v.get("he") or "")
430
+ filas.append(f"<div class='resultado'><span class='ref'>{ref}</span> — {txt}</div>")
431
+ if len(filas) >= 120:
432
+ return "".join(filas)
433
+ return "".join(filas) if filas else "<p>Sin resultados en los libros cargados.</p>"
434
+
435
+ q_btn.click(buscar, q, q_out)
436
+
437
+ with gr.Tab("Estudiar"):
438
+ gr.Markdown("La IA usa el capítulo abierto en **Leer** como contexto.")
439
+ chat = gr.Chatbot(type="messages", height=360)
440
+ pin = gr.Textbox(placeholder="Pregunta sobre el pasaje…", label="")
441
+ with gr.Row():
442
+ enviar = gr.Button("Preguntar", variant="primary")
443
+ comentar = gr.Button("Comentar este capítulo", variant="secondary")
444
+
445
+ def responder(mensaje, historial, es, he, nombre, cap):
446
+ mensaje = (mensaje or "").strip()
447
+ if not mensaje:
448
+ return historial, ""
449
+ contexto = f"Pasaje en pantalla — {nombre} {cap}:\nHebreo: {he}\nEspañol: {es}"
450
+ r = estudiar(mensaje, historial, contexto)
451
+ historial = (historial or []) + [
452
+ {"role": "user", "content": mensaje},
453
+ {"role": "assistant", "content": r},
454
+ ]
455
+ return historial, ""
456
+
457
+ enviar.click(responder, [pin, chat, st_es, st_he, st_nombre, st_cap], [chat, pin])
458
+ comentar.click(
459
+ lambda h, es, he, n, c: responder("Comenta y ayúdame a estudiar este capítulo.", h, es, he, n, c),
460
+ [chat, st_es, st_he, st_nombre, st_cap], [chat, pin],
461
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
+ with gr.Tab("Cómo subir"):
464
+ gr.Markdown(
465
+ "Sube un JSON por libro a la carpeta `libros/`. Para el estudio, añade el Secret `GROQ_API_KEY`."
466
+ )
467
+
468
+ if __name__ == "__main__":
469
+ demo.launch(ssr_mode=False)
470
+