Cristobal299 commited on
Commit
8706d7d
·
verified ·
1 Parent(s): 6724b32

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1121
app.py DELETED
@@ -1,1121 +0,0 @@
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>"""