Cristobal commited on
Commit
705d30f
·
1 Parent(s): 1e43b4f

boton nuevo video limpiar todo

Browse files
Files changed (2) hide show
  1. app.py +8 -0
  2. app_backup_antes_boton_1909.py +570 -0
app.py CHANGED
@@ -450,6 +450,7 @@ audio,video{width:100%;margin-top:10px;border-radius:8px}
450
  <div class="fase">
451
  <div class="fase-t">6. Publicacion</div>
452
  <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
 
453
  <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
454
  <div class="msg" id="msgPub"></div>
455
  </div>
@@ -533,6 +534,13 @@ function cargarVideo(){
533
  m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
534
  m.style.color="#00c853";
535
  }
 
 
 
 
 
 
 
536
  function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
537
  async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
538
 
 
450
  <div class="fase">
451
  <div class="fase-t">6. Publicacion</div>
452
  <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
453
+ <button onclick="nuevoVideo()" style="background:#222;color:#fff;border:1px solid #444;margin-top:8px">\ud83d\udd04 Nuevo Video (limpiar todo)</button>
454
  <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
455
  <div class="msg" id="msgPub"></div>
456
  </div>
 
534
  m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
535
  m.style.color="#00c853";
536
  }
537
+ async function nuevoVideo(){
538
+ if(!confirm("Esto borra el material, la voz y el video actual para empezar de cero. Continuar?"))return;
539
+ try{
540
+ await fetch("/reset",{method:"POST"});
541
+ location.reload();
542
+ }catch(e){alert("Error al limpiar");}
543
+ }
544
  function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
545
  async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
546
 
app_backup_antes_boton_1909.py ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time, json, glob, random, asyncio, base64
2
+ from fastapi import FastAPI, Request, UploadFile, File
3
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
4
+ from groq import Groq
5
+ import edge_tts, requests
6
+
7
+ app = FastAPI()
8
+
9
+ GROQ_KEYS = [k for k in [os.environ.get("GROQ_KEY",""), os.environ.get("GROQ_KEY_2",""), os.environ.get("GROQ_KEY_3","")] if k]
10
+ PEXELS_KEY = os.environ.get("PEXELS_KEY","")
11
+
12
+ # Estado de la sesion (guarda guion editado y material entre pasos)
13
+ ESTADO = {
14
+ "guion": "",
15
+ "archivos": [],
16
+ "voz_lista": False,
17
+ "video_listo": False,
18
+ }
19
+
20
+ ESTILOS = {
21
+ "narrativo": "narrativo y emocional, como contando una historia",
22
+ "educativo": "educativo y claro, explicando con autoridad",
23
+ "motivador": "motivador e inspirador, que mueva a la accion",
24
+ "publicitario": "publicitario y persuasivo, vendiendo sin parecer venta",
25
+ "misterio": "intrigante y de misterio, con ganchos de curiosidad",
26
+ }
27
+
28
+ VOCES = {
29
+ "es": {
30
+ "masculina": ["es-ES-AlvaroNeural","es-MX-JorgeNeural","es-AR-TomasNeural","es-CO-GonzaloNeural"],
31
+ "femenina": ["es-ES-ElviraNeural","es-MX-DaliaNeural","es-AR-ElenaNeural","es-CO-SalomeNeural"],
32
+ },
33
+ "en": {"masculina":["en-US-GuyNeural","en-GB-RyanNeural"],"femenina":["en-US-JennyNeural","en-GB-SoniaNeural"]},
34
+ }
35
+
36
+ def log(m): print(m, flush=True)
37
+
38
+ # ========== GUION ==========
39
+ def generar_guion(texto_base, estilo):
40
+ estilo_desc = ESTILOS.get(estilo, ESTILOS["narrativo"])
41
+ last_err = None
42
+ for k in GROQ_KEYS:
43
+ try:
44
+ client = Groq(api_key=k)
45
+ res = client.chat.completions.create(
46
+ messages=[{"role":"user","content":f"Basandote en este contenido:\n\n{texto_base}\n\nEscribe SOLO el texto a narrar en un video de 60 segundos. 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."}],
47
+ model="llama-3.3-70b-versatile", temperature=0.85
48
+ )
49
+ return res.choices[0].message.content.strip()
50
+ except Exception as e:
51
+ last_err = e
52
+ if "rate_limit" in str(e).lower() or "429" in str(e): continue
53
+ raise
54
+ raise last_err if last_err else Exception("Sin keys Groq")
55
+
56
+ # ========== VOZ ==========
57
+ async def generar_voz(guion, idioma="es", genero="masculina", pais=""):
58
+ voces = VOCES.get(idioma, VOCES["es"]).get(genero, ["es-ES-AlvaroNeural"])
59
+ if pais:
60
+ cods = {"españa":"ES","mexico":"MX","argentina":"AR","colombia":"CO","usa":"US","uk":"GB","australia":"AU"}
61
+ cod = cods.get(pais.lower(),"")
62
+ if cod:
63
+ filt = [v for v in voces if f"-{cod}-" in v]
64
+ if filt: voces = filt
65
+ voz_sel = random.choice(voces)
66
+ await edge_tts.Communicate(guion, voz_sel).save("voz.mp3")
67
+ return voz_sel
68
+
69
+ # ========== VIDEO ==========
70
+ def montar_video(guion, archivos, subs=None, efecto="ninguno"):
71
+ subs = subs or {}
72
+ from moviepy.editor import AudioFileClip as AC, VideoFileClip as VC, ImageClip as IC, TextClip, CompositeVideoClip, concatenate_videoclips
73
+ from PIL import Image as PILImage, ImageFilter as PIF, ImageFile
74
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
75
+
76
+ voz = AC("voz.mp3")
77
+ fondos = archivos[:8] if archivos else []
78
+
79
+ # Si no hay material propio, fondo de Pexels
80
+ if not fondos:
81
+ try:
82
+ h = {"Authorization": PEXELS_KEY}
83
+ r = requests.get("https://api.pexels.com/videos/search?query=abstract&per_page=8", headers=h)
84
+ for i, v in enumerate(r.json().get("videos", [])[:6]):
85
+ u = v["video_files"][0]["link"]
86
+ with open(f"fondo{i}.mp4","wb") as f: f.write(requests.get(u, timeout=30).content)
87
+ fondos.append(f"fondo{i}.mp4")
88
+ except Exception as e:
89
+ log(f"Error pexels: {e}")
90
+
91
+ dur_total = min(voz.duration, 58)
92
+ num_planos = max(int(dur_total / 4) + 1, len(fondos))
93
+ fondos_loop = (fondos * ((num_planos // max(len(fondos),1)) + 1))[:num_planos]
94
+ dur_clip = dur_total / num_planos
95
+ clips = []
96
+ for fp in fondos_loop:
97
+ try:
98
+ if fp.lower().endswith((".jpg",".jpeg",".png",".webp")):
99
+ img = PILImage.open(fp).convert("RGB")
100
+ w, h = img.size
101
+ bg = img.resize((1080,1920), PILImage.LANCZOS).filter(PIF.GaussianBlur(40))
102
+ ratio = min(1080/w, 1920/h)
103
+ fg = img.resize((int(w*ratio), int(h*ratio)), PILImage.LANCZOS)
104
+ bg.paste(fg, ((1080-fg.width)//2, (1920-fg.height)//2))
105
+ bg.save(fp+"_r.jpg","JPEG",quality=90)
106
+ ic = IC(fp+"_r.jpg").set_duration(dur_clip)
107
+ if efecto in ("zoom","zoom_fundido"):
108
+ ic = ic.resize(lambda t: 1 + 0.04*t).set_duration(dur_clip)
109
+ ic = ic.set_duration(dur_clip)
110
+ clips.append(ic)
111
+ else:
112
+ vclip = VC(fp).without_audio()
113
+ vclip = vclip.resize(height=1920) if vclip.w/vclip.h > 1080/1920 else vclip.resize(width=1080)
114
+ vclip = vclip.crop(x_center=vclip.w/2, y_center=vclip.h/2, width=1080, height=1920)
115
+ vclip = vclip.loop(duration=dur_clip).set_duration(dur_clip)
116
+ clips.append(vclip)
117
+ except Exception as e:
118
+ log(f"Error clip {fp}: {e}")
119
+
120
+ hay_video = any(str(f).lower().endswith((".mp4",".mov",".webm",".avi")) for f in fondos_loop)
121
+ if efecto in ("fundido","zoom_fundido") and len(clips) > 1 and not hay_video:
122
+ from moviepy.editor import concatenate_videoclips as _cc
123
+ clips_fx = [clips[0]] + [c2.crossfadein(0.5) for c2 in clips[1:]]
124
+ clip = _cc(clips_fx, method="compose", padding=-0.5).subclip(0, dur_total)
125
+ else:
126
+ clip = concatenate_videoclips(clips, method="compose").subclip(0, dur_total)
127
+ voz = voz.subclip(0, dur_total)
128
+
129
+ palabras = guion.split()
130
+ frases = [" ".join(palabras[i:i+6]) for i in range(0,len(palabras),6)]
131
+ t_f = voz.duration / max(len(frases),1)
132
+ sub_color = subs.get("color","#FFFFFF")
133
+ sub_size = {"pequeno":48,"mediano":64,"grande":80}.get(subs.get("tamano","mediano"),64)
134
+ sub_pos = {"arriba":0.15,"centro":0.5,"abajo":0.78}.get(subs.get("posicion","abajo"),0.78)
135
+ sub_activo = subs.get("activo",True)
136
+ if sub_activo:
137
+ txts = [TextClip(t.upper(), fontsize=sub_size, color=sub_color, font="/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
138
+ stroke_color="black", stroke_width=2, method="caption", size=(int(clip.w*0.8),None)
139
+ ).set_start(i*t_f).set_duration(t_f).set_pos(("center",sub_pos), relative=True)
140
+ for i,t in enumerate(frases)]
141
+ else:
142
+ txts = []
143
+
144
+ # Mezclar musica de fondo si el cliente la subio
145
+ import os as _os
146
+ audio_final = voz
147
+ if _os.path.exists("musica.mp3"):
148
+ try:
149
+ from moviepy.editor import CompositeAudioClip, AudioFileClip as _AC
150
+ musica = _AC("musica.mp3").volumex(0.18) # musica bajita para no tapar la voz
151
+ if musica.duration > voz.duration:
152
+ musica = musica.subclip(0, voz.duration)
153
+ else:
154
+ musica = musica.audio_loop(duration=voz.duration)
155
+ audio_final = CompositeAudioClip([voz.volumex(1.0), musica])
156
+ except Exception as _em:
157
+ log(f"Error mezcla musica: {_em}")
158
+ audio_final = voz
159
+ final = CompositeVideoClip([clip]+txts).set_audio(audio_final)
160
+ import os as _os2
161
+ if _os2.path.exists("preview.mp4"):
162
+ _os2.remove("preview.mp4")
163
+ final.write_videofile("preview_tmp.mp4", fps=20, codec="libx264", preset="ultrafast", logger=None, threads=4, audio_codec="aac")
164
+ # Renombrar solo cuando esta 100% terminado (evita cargar video a medias)
165
+ _os2.rename("preview_tmp.mp4", "preview.mp4")
166
+ return "preview.mp4"
167
+
168
+ # ========== ENDPOINTS ==========
169
+ @app.post("/generar-guion")
170
+ async def ep_guion(request: Request):
171
+ d = await request.json()
172
+ texto = d.get("texto","").strip()
173
+ estilo = d.get("estilo","narrativo")
174
+ parte = d.get("parte","auto")
175
+ # Si hay PDF completo cargado y el usuario no edito el texto a mano, usar el trozo elegido
176
+ completo = ESTADO.get("texto_completo","")
177
+ if completo and len(completo) > 6000:
178
+ if parte == "principio":
179
+ texto = completo[:6000]
180
+ elif parte == "mitad":
181
+ m = len(completo)//2
182
+ texto = completo[m-3000:m+3000]
183
+ elif parte == "final":
184
+ texto = completo[-6000:]
185
+ else: # auto = trozo al azar
186
+ import random as _r
187
+ ini = _r.randint(0, max(0, len(completo)-6000))
188
+ texto = completo[ini:ini+6000]
189
+ if not texto:
190
+ return JSONResponse({"error":"Pega tu contenido base primero"}, status_code=400)
191
+ try:
192
+ guion = generar_guion(texto, estilo)
193
+ ESTADO["guion"] = guion
194
+ return {"guion": guion}
195
+ except Exception as e:
196
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
197
+
198
+ @app.post("/subir-material")
199
+ async def ep_material(file: UploadFile = File(...)):
200
+ try:
201
+ import subprocess as _sp
202
+ ext = file.filename.split(".")[-1].lower()
203
+ idx = len(ESTADO['archivos'])
204
+ nombre = f"media_{idx}.{ext}"
205
+ with open(nombre,"wb") as f:
206
+ f.write(await file.read())
207
+ ESTADO["archivos"].append(nombre)
208
+ return {"ok": True, "total": len(ESTADO["archivos"]), "nombre": nombre}
209
+ except Exception as e:
210
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
211
+
212
+ @app.post("/generar-voz")
213
+ async def ep_voz(request: Request):
214
+ d = await request.json()
215
+ guion = d.get("guion","").strip()
216
+ if not guion:
217
+ return JSONResponse({"error":"No hay guion"}, status_code=400)
218
+ ESTADO["guion"] = guion
219
+ try:
220
+ await generar_voz(guion, d.get("idioma","es"), d.get("genero","masculina"), d.get("pais",""))
221
+ ESTADO["voz_lista"] = True
222
+ return {"ok": True}
223
+ except Exception as e:
224
+ return JSONResponse({"error": str(e)[:100]}, status_code=500)
225
+
226
+
227
+ @app.post("/subir-voz-grabada")
228
+ async def ep_voz_grabada(file: UploadFile = File(...)):
229
+ try:
230
+ import subprocess
231
+ contenido = await file.read()
232
+ with open("voz_grabada_raw","wb") as f:
233
+ f.write(contenido)
234
+ # Convertir a mp3 con ffmpeg (el navegador graba en webm/ogg)
235
+ subprocess.run(["ffmpeg","-y","-i","voz_grabada_raw","-acodec","libmp3lame","voz.mp3"], capture_output=True)
236
+ import os as _os
237
+ if _os.path.exists("voz.mp3") and _os.path.getsize("voz.mp3") > 1000:
238
+ ESTADO["voz_lista"] = True
239
+ return {"ok": True}
240
+ return JSONResponse({"error":"No se pudo procesar el audio"}, status_code=500)
241
+ except Exception as e:
242
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
243
+
244
+
245
+ @app.post("/subir-musica")
246
+ async def ep_musica(file: UploadFile = File(...)):
247
+ try:
248
+ import subprocess, os as _os
249
+ contenido = await file.read()
250
+ with open("musica_raw","wb") as f:
251
+ f.write(contenido)
252
+ subprocess.run(["ffmpeg","-y","-i","musica_raw","-acodec","libmp3lame","musica.mp3"], capture_output=True)
253
+ if _os.path.exists("musica.mp3") and _os.path.getsize("musica.mp3") > 1000:
254
+ ESTADO["musica"] = True
255
+ return {"ok": True}
256
+ return JSONResponse({"error":"No se pudo procesar la musica"}, status_code=500)
257
+ except Exception as e:
258
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
259
+
260
+ @app.get("/audio")
261
+ def ep_audio():
262
+ if os.path.exists("voz.mp3"):
263
+ return FileResponse("voz.mp3", media_type="audio/mpeg")
264
+ return JSONResponse({"error":"sin audio"}, status_code=404)
265
+
266
+ @app.post("/generar-video")
267
+ async def ep_video(request: Request):
268
+ d = await request.json()
269
+ guion = d.get("guion","").strip() or ESTADO["guion"]
270
+ if not os.path.exists("voz.mp3"):
271
+ return JSONResponse({"error":"Genera la voz primero"}, status_code=400)
272
+ subs = {
273
+ "color": d.get("sub_color","#FFFFFF"),
274
+ "tamano": d.get("sub_tamano","mediano"),
275
+ "posicion": d.get("sub_posicion","abajo"),
276
+ "activo": d.get("sub_activo", True),
277
+ }
278
+ efecto = d.get("efecto","ninguno")
279
+ try:
280
+ loop = asyncio.get_event_loop()
281
+ await loop.run_in_executor(None, montar_video, guion, ESTADO["archivos"], subs, efecto)
282
+ ESTADO["video_listo"] = True
283
+ return {"ok": True}
284
+ except Exception as e:
285
+ import traceback
286
+ tb = traceback.format_exc()
287
+ print("ERROR VIDEO:", tb, flush=True)
288
+ return JSONResponse({"error": str(e)[:300]}, status_code=500)
289
+
290
+
291
+ @app.get("/video-listo")
292
+ def ep_video_listo():
293
+ import os as _os
294
+ if _os.path.exists("preview.mp4") and _os.path.getsize("preview.mp4") > 5000:
295
+ return {"listo": True, "mtime": _os.path.getmtime("preview.mp4")}
296
+ return {"listo": False}
297
+
298
+ @app.get("/video")
299
+ def ep_video_file():
300
+ if os.path.exists("preview.mp4"):
301
+ return FileResponse("preview.mp4", media_type="video/mp4")
302
+ return JSONResponse({"error":"sin video"}, status_code=404)
303
+
304
+ @app.post("/reset")
305
+ def ep_reset():
306
+ for f in glob.glob("media_*")+glob.glob("fondo*")+["voz.mp3","preview.mp4"]:
307
+ try: os.remove(f)
308
+ except: pass
309
+ ESTADO["guion"]=""; ESTADO["archivos"]=[]; ESTADO["voz_lista"]=False; ESTADO["video_listo"]=False
310
+ return {"ok": True}
311
+
312
+ @app.post("/subir-pdf")
313
+ async def ep_pdf(file: UploadFile = File(...)):
314
+ try:
315
+ contenido = await file.read()
316
+ with open("temp.pdf","wb") as f:
317
+ f.write(contenido)
318
+ from pypdf import PdfReader
319
+ reader = PdfReader("temp.pdf")
320
+ texto = ""
321
+ for pagina in reader.pages:
322
+ texto += (pagina.extract_text() or "") + "\n"
323
+ texto = texto.strip()
324
+ if not texto:
325
+ return JSONResponse({"error":"El PDF no tiene texto extraible (puede ser escaneado)"}, status_code=400)
326
+ ESTADO["texto_completo"] = texto
327
+ # Mostrar preview de los primeros 6000 al usuario
328
+ return {"ok": True, "texto": texto[:6000], "caracteres": len(texto)}
329
+ except Exception as e:
330
+ return JSONResponse({"error": str(e)[:120]}, status_code=500)
331
+
332
+ # ========== INTERFAZ ==========
333
+ @app.get("/", response_class=HTMLResponse)
334
+ def home():
335
+ return """<!DOCTYPE html>
336
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
337
+ <title>Fenix Hybrid Engine</title>
338
+ <style>
339
+ *{box-sizing:border-box;margin:0;padding:0}
340
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,sans-serif;padding:20px;max-width:780px;margin:0 auto;line-height:1.5}
341
+ h1{font-size:1.8rem;background:linear-gradient(90deg,#ff6b00,#ffb700);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:4px}
342
+ .sub{color:#888;margin-bottom:24px;font-size:.95rem}
343
+ .fase{background:#111;border:1px solid #222;border-radius:14px;padding:20px;margin-bottom:16px}
344
+ .fase-t{font-size:.8rem;color:#ff8c00;font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
345
+ 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}
346
+ textarea{min-height:90px;resize:vertical}
347
+ 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}
348
+ button:disabled{opacity:.4}
349
+ .btn2{background:#222;color:#fff;border:1px solid #444}
350
+ .msg{font-size:.85rem;margin-top:8px;min-height:18px}
351
+ audio,video{width:100%;margin-top:10px;border-radius:8px}
352
+ .lbl{font-size:.85rem;color:#aaa;margin-bottom:6px;display:block}
353
+ .row{display:flex;gap:8px}.row>*{flex:1}
354
+ .tag{display:inline-block;background:#1a1a1a;color:#ff8c00;padding:2px 10px;border-radius:20px;font-size:.75rem;margin-left:6px}
355
+
356
+ .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}
357
+ @keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}
358
+ .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}
359
+ </style></head><body>
360
+ <h1>Fenix Hybrid Engine</h1>
361
+ <div class="sub">Motor de produccion asistida. La IA acelera, tu tienes el control.</div>
362
+
363
+ <div class="fase">
364
+ <div class="fase-t">1. Ingesta de conocimiento</div>
365
+ <span class="lbl">Sube un PDF (extrae el texto solo) o pega tu contenido:</span>
366
+ <input type="file" id="pdf" accept=".pdf" onchange="subirPdf()">
367
+ <div class="msg" id="msgPdf"></div>
368
+ <textarea id="texto" placeholder="Pega aqui tu base de conocimiento, o sube un PDF arriba..."></textarea>
369
+ <span class="lbl">Estilo de narrativa:</span>
370
+ <select id="estilo">
371
+ <option value="narrativo">Narrativo y emocional</option>
372
+ <option value="educativo">Educativo y claro</option>
373
+ <option value="motivador">Motivador e inspirador</option>
374
+ <option value="publicitario">Publicitario y persuasivo</option>
375
+ <option value="misterio">Misterio e intriga</option>
376
+ </select>
377
+ <span class="lbl">Que parte del documento usar (si subiste PDF largo):</span>
378
+ <select id="parte">
379
+ <option value="auto">Automatico (trozos al azar, mas variedad)</option>
380
+ <option value="principio">Principio del documento</option>
381
+ <option value="mitad">Mitad del documento</option>
382
+ <option value="final">Final del documento</option>
383
+ </select>
384
+ <button onclick="genGuion()">Generar Propuesta de Guion</button>
385
+ <div class="msg" id="msgGuion"></div>
386
+ </div>
387
+
388
+ <div class="fase">
389
+ <div class="fase-t">2. Edicion del guion <span class="tag">control humano</span></div>
390
+ <span class="lbl">Edita el guion a tu gusto antes de continuar:</span>
391
+ <textarea id="guion" placeholder="Aqui aparecera el guion generado, editable..."></textarea>
392
+ </div>
393
+
394
+ <div class="fase">
395
+ <div class="fase-t">3. Tu material</div>
396
+ <span class="lbl">Sube tus imagenes o videos (uno a uno):</span>
397
+ <input type="file" id="material" accept="image/*,video/*" multiple onchange="subirMaterial()">
398
+ <div class="msg" id="msgMaterial">Sin material aun (usara fondos automaticos)</div>
399
+ </div>
400
+
401
+ <div class="fase">
402
+ <div class="fase-t">4. Voz</div>
403
+ <select id="tipoVoz" onchange="cambioVoz()">
404
+ <option value="ia">Voz IA (automatica)</option>
405
+ <option value="grabada">Mi voz grabada (con microfono)</option>
406
+ </select>
407
+ <div id="vozIA">
408
+ <div class="row">
409
+ <select id="genero"><option value="masculina">Voz masculina</option><option value="femenina">Voz femenina</option></select>
410
+ <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>
411
+ </div>
412
+ <button class="btn2" onclick="genVoz()">Generar y Pre-escuchar Voz</button>
413
+ </div>
414
+ <div id="vozGrabada" style="display:none">
415
+ <p style="color:#aaa;font-size:.85rem;margin-bottom:8px">Lee el guion de arriba en voz alta y grabate:</p>
416
+ <button class="btn2" id="btnRec" onclick="toggleRec()">Empezar a grabar</button>
417
+ </div>
418
+ <div class="msg" id="msgVoz"></div>
419
+ <audio id="player" controls style="display:none"></audio>
420
+ <div style="margin-top:14px;padding-top:14px;border-top:1px solid #222">
421
+ <span class="lbl">Musica de fondo (opcional, sonara bajita bajo la voz):</span>
422
+ <input type="file" id="musica" accept="audio/*" onchange="subirMusica()">
423
+ <div class="msg" id="msgMusica"></div>
424
+ </div>
425
+ </div>
426
+
427
+ <div class="fase">
428
+ <div class="fase-t">5. Subtitulos y Montaje</div>
429
+ <div class="row">
430
+ <select id="subActivo"><option value="si">Con subtitulos</option><option value="no">Sin subtitulos</option></select>
431
+ <select id="subTamano"><option value="pequeno">Pequenos</option><option value="mediano" selected>Medianos</option><option value="grande">Grandes</option></select>
432
+ </div>
433
+ <div class="row">
434
+ <select id="subPosicion"><option value="arriba">Arriba</option><option value="centro">Centro</option><option value="abajo" selected>Abajo</option></select>
435
+ <select id="subColor"><option value="#FFFFFF">Blanco</option><option value="#FFD700">Dorado</option><option value="#FFFF00">Amarillo</option><option value="#00E5FF">Cian</option><option value="#FF4081">Rosa</option><option value="#76FF03">Verde</option></select>
436
+ </div>
437
+ <span class="lbl">Efecto de video:</span>
438
+ <select id="efecto">
439
+ <option value="ninguno">Sin efecto (estatico)</option>
440
+ <option value="zoom">Zoom lento (Ken Burns)</option>
441
+ <option value="fundido">Fundido suave entre planos</option>
442
+ <option value="zoom_fundido">Zoom + Fundido (cinematografico)</option>
443
+ </select>
444
+ <button onclick="genVideo()">Generar Vista Previa del Video</button>
445
+ <button class="btn2" onclick="cargarVideo()" style="margin-top:8px">Cargar Video (cuando termine)</button>
446
+ <div class="msg" id="msgVideo"></div>
447
+ <video id="vid" controls playsinline webkit-playsinline preload="metadata" style="display:none"></video>
448
+ </div>
449
+
450
+ <div class="fase">
451
+ <div class="fase-t">6. Publicacion</div>
452
+ <button onclick="descargar()" style="background:linear-gradient(90deg,#00c853,#64dd17)">Descargar Video</button>
453
+ <button class="btn2" onclick="subirYt()" style="margin-top:8px">Conectar YouTube y Subir</button>
454
+ <div class="msg" id="msgPub"></div>
455
+ </div>
456
+
457
+ <script>
458
+ async function subirPdf(){
459
+ var f=document.getElementById("pdf").files[0]; if(!f)return;
460
+ var m=document.getElementById("msgPdf"); m.textContent="Extrayendo texto...";m.style.color="#ffb700";
461
+ var fd=new FormData(); fd.append("file",f);
462
+ var r=await fetch("/subir-pdf",{method:"POST",body:fd}); var d=await r.json();
463
+ if(d.ok){document.getElementById("texto").value=d.texto;m.textContent="PDF leido: "+d.caracteres+" caracteres";m.style.color="#00c853";}
464
+ else{m.textContent=d.error;m.style.color="#ff5252";}
465
+ }
466
+ async function genGuion(){
467
+ var m=document.getElementById("msgGuion");m.textContent="Generando...";m.style.color="#ffb700";
468
+ 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})});
469
+ var d=await r.json();
470
+ if(d.guion){document.getElementById("guion").value=d.guion;m.textContent="Guion listo. Editalo abajo si quieres.";m.style.color="#00c853";}
471
+ else{m.textContent=d.error;m.style.color="#ff5252";}
472
+ }
473
+ async function subirMaterial(){
474
+ var files=document.getElementById("material").files; if(!files.length)return;
475
+ var m=document.getElementById("msgMaterial");
476
+ var total=0;
477
+ for(var i=0;i<files.length;i++){
478
+ m.textContent="Subiendo "+(i+1)+" de "+files.length+"...";m.style.color="#ffb700";
479
+ var fd=new FormData(); fd.append("file",files[i]);
480
+ var r=await fetch("/subir-material",{method:"POST",body:fd}); var d=await r.json();
481
+ if(d.ok){total=d.total;}
482
+ }
483
+ m.textContent=total+" archivo(s) subido(s) en total";m.style.color="#00c853";
484
+ }
485
+ async function genVoz(){
486
+ var m=document.getElementById("msgVoz");m.textContent="Generando voz...";m.style.color="#ffb700";
487
+ var r=await fetch("/generar-voz",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({guion:document.getElementById("guion").value,genero:document.getElementById("genero").value,pais:document.getElementById("pais").value})});
488
+ var d=await r.json();
489
+ 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";}
490
+ else{m.textContent=d.error;m.style.color="#ff5252";}
491
+ }
492
+ var pollTimer=null;
493
+ async function genVideo(){
494
+ var m=document.getElementById("msgVideo");
495
+ m.innerHTML="<span class=\'spinner\'></span> Montando video...";m.style.color="#ffb700";
496
+ // Lanzar el render sin esperar la respuesta (evita timeout)
497
+ fetch("/generar-video",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({
498
+ guion:document.getElementById("guion").value,
499
+ sub_activo:document.getElementById("subActivo").value==="si",
500
+ sub_tamano:document.getElementById("subTamano").value,
501
+ sub_posicion:document.getElementById("subPosicion").value,
502
+ sub_color:document.getElementById("subColor").value,
503
+ efecto:document.getElementById("efecto").value
504
+ })}).catch(e=>{});
505
+ // Comprobar cada 4 segundos si el video esta listo
506
+ var inicio=Date.now();
507
+ if(pollTimer)clearInterval(pollTimer);
508
+ pollTimer=setInterval(async()=>{
509
+ try{
510
+ var r=await fetch("/video-listo?t="+Date.now());var d=await r.json();
511
+ if(d.listo && d.mtime*1000 > inicio){
512
+ clearInterval(pollTimer);
513
+ var u="/video?t="+Date.now();
514
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
515
+ 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>";
516
+ m.style.color="#00c853";
517
+ }
518
+ }catch(e){}
519
+ },4000);
520
+ }
521
+ async function subirMusica(){
522
+ var f=document.getElementById("musica").files[0]; if(!f)return;
523
+ var m=document.getElementById("msgMusica");m.textContent="Subiendo musica...";m.style.color="#ffb700";
524
+ var fd=new FormData(); fd.append("file",f);
525
+ var r=await fetch("/subir-musica",{method:"POST",body:fd}); var d=await r.json();
526
+ if(d.ok){m.textContent="✅ Musica anadida, sonara de fondo";m.style.color="#00c853";}
527
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
528
+ }
529
+ function cargarVideo(){
530
+ var u="/video?t="+Date.now();
531
+ var v=document.getElementById("vid");v.src=u;v.style.display="block";v.load();
532
+ var m=document.getElementById("msgVideo");
533
+ m.innerHTML="▶ <a href=\'"+u+"\' target=\'_blank\' style=\'color:#00E5FF;font-weight:800\'>VER VIDEO EN PANTALLA COMPLETA</a>";
534
+ m.style.color="#00c853";
535
+ }
536
+ function descargar(){var a=document.createElement("a");a.href="/video?t="+Date.now();a.download="video_fenix.mp4";a.click();}
537
+ async function subirYt(){var m=document.getElementById("msgPub");m.textContent="Conecta tu canal de YouTube para activar la subida automatica";m.style.color="#ffb700";}
538
+
539
+ function cambioVoz(){
540
+ var t=document.getElementById("tipoVoz").value;
541
+ document.getElementById("vozIA").style.display=t==="ia"?"block":"none";
542
+ document.getElementById("vozGrabada").style.display=t==="grabada"?"block":"none";
543
+ }
544
+ var mediaRec=null, chunks=[], grabando=false;
545
+ async function toggleRec(){
546
+ var btn=document.getElementById("btnRec"); var m=document.getElementById("msgVoz");
547
+ if(!grabando){
548
+ try{
549
+ var stream=await navigator.mediaDevices.getUserMedia({audio:true});
550
+ mediaRec=new MediaRecorder(stream); chunks=[];
551
+ mediaRec.ondataavailable=e=>chunks.push(e.data);
552
+ mediaRec.onstop=async()=>{
553
+ var blob=new Blob(chunks,{type:"audio/webm"});
554
+ m.textContent="Subiendo tu voz...";m.style.color="#ffb700";
555
+ var fd=new FormData(); fd.append("file",blob,"voz.webm");
556
+ var r=await fetch("/subir-voz-grabada",{method:"POST",body:fd}); var d=await r.json();
557
+ 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";}
558
+ else{m.textContent="❌ "+(d.error||"Error");m.style.color="#ff5252";}
559
+ };
560
+ mediaRec.start(); grabando=true;
561
+ btn.textContent="⏹ Detener grabacion"; btn.style.background="#ff4444";
562
+ m.textContent="Grabando... lee el guion";m.style.color="#ff4444";
563
+ }catch(e){m.textContent="❌ No se pudo acceder al microfono";m.style.color="#ff5252";}
564
+ }else{
565
+ mediaRec.stop(); grabando=false;
566
+ btn.textContent="Empezar a grabar"; btn.style.background="";
567
+ }
568
+ }
569
+ </script>
570
+ </body></html>"""