gnosticdev commited on
Commit
74c0237
·
verified ·
1 Parent(s): 8b182fa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +237 -419
app.py CHANGED
@@ -9,15 +9,14 @@ import gradio as gr
9
  import torch
10
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
11
  from keybert import KeyBERT
12
- # Importación correcta
13
  from moviepy.editor import VideoFileClip, concatenate_videoclips, AudioFileClip, CompositeAudioClip, concatenate_audioclips, AudioClip
14
  import re
15
  import math
16
  import shutil
17
  import json
18
  from collections import Counter
 
19
 
20
- # Configuración de logging
21
  logging.basicConfig(
22
  level=logging.DEBUG,
23
  format='%(asctime)s - %(levelname)s - %(message)s',
@@ -31,8 +30,6 @@ logger.info("="*80)
31
  logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
32
  logger.info("="*80)
33
 
34
- # Diccionario de voces TTS disponibles organizadas por idioma
35
- # Puedes expandir esta lista si conoces otros IDs de voz de Edge TTS
36
  VOCES_DISPONIBLES = {
37
  "Español (España)": {
38
  "es-ES-JuanNeural": "Juan (España) - Masculino",
@@ -95,44 +92,30 @@ VOCES_DISPONIBLES = {
95
  }
96
  }
97
 
98
- # Función para obtener lista plana de voces para el dropdown
99
  def get_voice_choices():
100
  choices = []
101
  for region, voices in VOCES_DISPONIBLES.items():
102
  for voice_id, voice_name in voices.items():
103
- # Formato: (Texto a mostrar en el dropdown, Valor que se pasa)
104
  choices.append((f"{voice_name} ({region})", voice_id))
105
  return choices
106
 
107
- # Obtener las voces al inicio del script
108
- # Usamos la lista predefinida por ahora para evitar el error de inicio con la API
109
- # Si deseas obtenerlas dinámicamente, descomenta la siguiente línea y comenta la que usa get_voice_choices()
110
- # AVAILABLE_VOICES = asyncio.run(get_available_voices())
111
- AVAILABLE_VOICES = get_voice_choices() # <-- Usamos la lista predefinida y aplanada
112
- # Establecer una voz por defecto inicial
113
- DEFAULT_VOICE_ID = "es-ES-JuanNeural" # ID de Juan
114
-
115
- # Buscar el nombre amigable para la voz por defecto si existe
116
  DEFAULT_VOICE_NAME = DEFAULT_VOICE_ID
117
  for text, voice_id in AVAILABLE_VOICES:
118
  if voice_id == DEFAULT_VOICE_ID:
119
  DEFAULT_VOICE_NAME = text
120
  break
121
- # Si Juan no está en la lista (ej. lista de fallback), usar la primera voz disponible
122
  if DEFAULT_VOICE_ID not in [v[1] for v in AVAILABLE_VOICES]:
123
  DEFAULT_VOICE_ID = AVAILABLE_VOICES[0][1] if AVAILABLE_VOICES else "en-US-AriaNeural"
124
- DEFAULT_VOICE_NAME = AVAILABLE_VOICES[0][0] if AVAILABLE_VOICES else "Aria (United States) - Female" # Fallback name
125
 
126
  logger.info(f"Voz por defecto seleccionada (ID): {DEFAULT_VOICE_ID}")
127
 
128
-
129
- # Clave API de Pexels
130
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
131
  if not PEXELS_API_KEY:
132
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
133
- # raise ValueError("API key de Pexels no configurada")
134
 
135
- # Inicialización de modelos
136
  MODEL_NAME = "datificate/gpt2-small-spanish"
137
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
138
  tokenizer = None
@@ -160,7 +143,6 @@ def buscar_videos_pexels(query, api_key, per_page=5):
160
  if not api_key:
161
  logger.warning("No se puede buscar en Pexels: API Key no configurada.")
162
  return []
163
-
164
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
165
  headers = {"Authorization": api_key}
166
  try:
@@ -170,7 +152,6 @@ def buscar_videos_pexels(query, api_key, per_page=5):
170
  "orientation": "landscape",
171
  "size": "medium"
172
  }
173
-
174
  response = requests.get(
175
  "https://api.pexels.com/videos/search",
176
  headers=headers,
@@ -178,19 +159,16 @@ def buscar_videos_pexels(query, api_key, per_page=5):
178
  timeout=20
179
  )
180
  response.raise_for_status()
181
-
182
  data = response.json()
183
  videos = data.get('videos', [])
184
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
185
  return videos
186
-
187
  except requests.exceptions.RequestException as e:
188
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
189
  except json.JSONDecodeError:
190
  logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
191
  except Exception as e:
192
  logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
193
-
194
  return []
195
 
196
  def generate_script(prompt, max_length=150):
@@ -198,16 +176,13 @@ def generate_script(prompt, max_length=150):
198
  if not tokenizer or not model:
199
  logger.warning("Modelos GPT-2 no disponibles - Usando prompt original como guion.")
200
  return prompt.strip()
201
-
202
  instruction_phrase_start = "Escribe un guion corto, interesante y coherente sobre:"
203
  ai_prompt = f"{instruction_phrase_start} {prompt}"
204
-
205
  try:
206
  inputs = tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=512)
207
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
208
  model.to(device)
209
  inputs = {k: v.to(device) for k, v in inputs.items()}
210
-
211
  outputs = model.generate(
212
  **inputs,
213
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
@@ -220,84 +195,59 @@ def generate_script(prompt, max_length=150):
220
  eos_token_id=tokenizer.eos_token_id,
221
  no_repeat_ngram_size=3
222
  )
223
-
224
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
225
-
226
  cleaned_text = text.strip()
227
- # Limpieza mejorada de la frase de instrucción
228
  try:
229
- # Buscar el índice de inicio del prompt original dentro del texto generado
230
  prompt_in_output_idx = text.lower().find(prompt.lower())
231
  if prompt_in_output_idx != -1:
232
- # Tomar todo el texto DESPUÉS del prompt original
233
  cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
234
  logger.debug("Texto limpiado tomando parte después del prompt original.")
235
  else:
236
- # Fallback si el prompt original no está exacto en la salida: buscar la frase de instrucción base
237
- instruction_start_idx = text.find(instruction_phrase_start)
238
- if instruction_start_idx != -1:
239
- # Tomar texto después de la frase base (puede incluir el prompt)
240
- cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
241
- logger.debug("Texto limpiado tomando parte después de la frase de instrucción base.")
242
- else:
243
- # Si ni la frase de instrucción ni el prompt se encuentran, usar el texto original
244
- logger.warning("No se pudo identificar el inicio del guión generado. Usando texto generado completo.")
245
- cleaned_text = text.strip() # Limpieza básica
246
-
247
-
248
  except Exception as e:
249
- logger.warning(f"Error durante la limpieza heurística del guión de IA: {e}. Usando texto generado sin limpieza adicional.")
250
- cleaned_text = re.sub(r'<[^>]+>', '', text).strip() # Limpieza básica como fallback
251
-
252
- # Asegurarse de que el texto resultante no sea solo la instrucción o vacío
253
- if not cleaned_text or len(cleaned_text) < 10: # Umbral de longitud mínima
254
- logger.warning("El guión generado parece muy corto o vacío después de la limpieza heurística. Usando el texto generado original (sin limpieza adicional).")
255
- cleaned_text = re.sub(r'<[^>]+>', '', text).strip() # Fallback al texto original limpio
256
-
257
- # Limpieza final de caracteres especiales y espacios sobrantes
258
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
259
- cleaned_text = cleaned_text.lstrip(':').strip() # Quitar posibles ':' al inicio
260
- cleaned_text = cleaned_text.lstrip('.').strip() # Quitar posibles '.' al inicio
261
-
262
-
263
- # Intentar obtener al menos una oración completa si es posible para un inicio más limpio
264
  sentences = cleaned_text.split('.')
265
  if sentences and sentences[0].strip():
266
  final_text = sentences[0].strip() + '.'
267
- # Añadir la segunda oración si existe y es razonable
268
- if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7: # Usar un 70% de max_length como umbral
269
- final_text += " " + sentences[1].strip() + "."
270
- final_text = final_text.replace("..", ".") # Limpiar doble punto
271
-
272
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
273
  return final_text.strip()
274
-
275
  logger.info(f"Guion generado final (sin oraciones completas detectadas - Truncado): '{cleaned_text[:100]}...'")
276
- return cleaned_text.strip() # Si no se puede formar una oración, devolver el texto limpio tal cual
277
-
278
  except Exception as e:
279
  logger.error(f"Error generando guion con GPT-2 (fuera del bloque de limpieza): {str(e)}", exc_info=True)
280
  logger.warning("Usando prompt original como guion debido al error de generación.")
281
  return prompt.strip()
282
 
283
- # Función TTS ahora recibe la voz a usar
284
  async def text_to_speech(text, output_path, voice):
285
  logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice} | Salida: {output_path}")
286
  if not text or not text.strip():
287
  logger.warning("Texto vacío para TTS")
288
  return False
289
-
290
  try:
291
  communicate = edge_tts.Communicate(text, voice)
292
  await communicate.save(output_path)
293
-
294
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
295
  logger.info(f"Audio guardado exitosamente en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
296
  return True
297
  else:
298
  logger.error(f"TTS guardó un archivo pequeño o vacío en: {output_path}")
299
  return False
300
-
301
  except Exception as e:
302
  logger.error(f"Error en TTS con voz '{voice}': {str(e)}", exc_info=True)
303
  return False
@@ -306,113 +256,97 @@ def download_video_file(url, temp_dir):
306
  if not url:
307
  logger.warning("URL de video no proporcionada para descargar")
308
  return None
309
-
310
  try:
311
  logger.info(f"Descargando video desde: {url[:80]}...")
312
  os.makedirs(temp_dir, exist_ok=True)
313
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
314
  output_path = os.path.join(temp_dir, file_name)
315
-
316
  with requests.get(url, stream=True, timeout=60) as r:
317
  r.raise_for_status()
318
  with open(output_path, 'wb') as f:
319
  for chunk in r.iter_content(chunk_size=8192):
320
  f.write(chunk)
321
-
322
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
323
- logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
324
- return output_path
325
  else:
326
- logger.warning(f"Descarga parece incompleta o vacía para {url[:80]}... Archivo: {output_path} Tamaño: {os.path.getsize(output_path) if os.path.exists(output_path) else 'N/A'} bytes")
327
- if os.path.exists(output_path):
328
- os.remove(output_path)
329
- return None
330
-
331
  except requests.exceptions.RequestException as e:
332
  logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
333
  except Exception as e:
334
  logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
335
-
336
  return None
337
 
338
  def loop_audio_to_length(audio_clip, target_duration):
339
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
340
-
341
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
342
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
343
  try:
344
  sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
345
- return AudioClip(lambda t: 0, duration=target_duration, sr=sr)
346
  except Exception as e:
347
- logger.error(f"Could not create silence clip: {e}", exc_info=True)
348
- return AudioFileClip(filename="")
349
-
350
  if audio_clip.duration >= target_duration:
351
  logger.debug("Audio clip already longer or equal to target. Trimming.")
352
  trimmed_clip = audio_clip.subclip(0, target_duration)
353
  if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
354
- logger.error("Trimmed audio clip is invalid.")
355
- try: trimmed_clip.close()
356
- except: pass
357
- return AudioFileClip(filename="")
 
 
358
  return trimmed_clip
359
-
360
  loops = math.ceil(target_duration / audio_clip.duration)
361
  logger.debug(f"Creando {loops} loops de audio")
362
-
363
  audio_segments = [audio_clip] * loops
364
  looped_audio = None
365
  final_looped_audio = None
366
  try:
367
- looped_audio = concatenate_audioclips(audio_segments)
368
-
369
- if looped_audio.duration is None or looped_audio.duration <= 0:
370
  logger.error("Concatenated audio clip is invalid (None or zero duration).")
371
  raise ValueError("Invalid concatenated audio.")
372
-
373
- final_looped_audio = looped_audio.subclip(0, target_duration)
374
-
375
- if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
376
  logger.error("Final subclipped audio clip is invalid (None or zero duration).")
377
  raise ValueError("Invalid final subclipped audio.")
378
-
379
- return final_looped_audio
380
-
381
  except Exception as e:
382
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
383
  try:
384
- if audio_clip.duration is not None and audio_clip.duration > 0:
385
- logger.warning("Returning original audio clip (may be too short).")
386
- return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
387
  except:
388
- pass
389
  logger.error("Fallback to original audio clip failed.")
390
  return AudioFileClip(filename="")
391
-
392
  finally:
393
  if looped_audio is not None and looped_audio is not final_looped_audio:
394
- try: looped_audio.close()
395
- except: pass
396
-
 
397
 
398
  def extract_visual_keywords_from_script(script_text):
399
  logger.info("Extrayendo palabras clave del guion")
400
  if not script_text or not script_text.strip():
401
  logger.warning("Guion vacío, no se pueden extraer palabras clave.")
402
  return ["naturaleza", "ciudad", "paisaje"]
403
-
404
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
405
  keywords_list = []
406
-
407
  if kw_model:
408
  try:
409
  logger.debug("Intentando extracción con KeyBERT...")
410
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
411
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
412
-
413
  all_keywords = keywords1 + keywords2
414
  all_keywords.sort(key=lambda item: item[1], reverse=True)
415
-
416
  seen_keywords = set()
417
  for keyword, score in all_keywords:
418
  formatted_keyword = keyword.lower().replace(" ", "+")
@@ -421,46 +355,38 @@ def extract_visual_keywords_from_script(script_text):
421
  seen_keywords.add(formatted_keyword)
422
  if len(keywords_list) >= 5:
423
  break
424
-
425
  if keywords_list:
426
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
427
  return keywords_list
428
-
429
  except Exception as e:
430
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
431
-
432
  logger.debug("Extrayendo palabras clave con método simple...")
433
  words = clean_text.lower().split()
434
  stop_words = {"el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con", "para", "del", "al", "por", "su", "sus", "se", "lo", "le", "me", "te", "nos", "os", "les", "mi", "tu",
435
  "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
436
  "nuestros", "vuestros", "estas", "esas", "aquellas", "si", "no", "más", "menos", "sin", "sobre", "bajo", "entre", "hasta", "desde", "durante", "mediante", "según", "versus", "via", "cada", "todo", "todos", "toda", "todas", "poco", "pocos", "poca", "pocas", "mucho", "muchos", "mucha", "muchas", "varios", "varias", "otro", "otros", "otra", "otras", "mismo", "misma", "mismos", "mismas", "tan", "tanto", "tanta", "tantos", "tantas", "tal", "tales", "cual", "cuales", "cuyo", "cuya", "cuyos", "cuyas", "quien", "quienes", "cuan", "cuanto", "cuanta", "cuantos", "cuantas", "como", "donde", "cuando", "porque", "aunque", "mientras", "siempre", "nunca", "jamás", "muy", "casi", "solo", "solamente", "incluso", "apenas", "quizás", "tal vez", "acaso", "claro", "cierto", "obvio", "evidentemente", "realmente", "simplemente", "generalmente", "especialmente", "principalmente", "posiblemente", "probablemente", "difícilmente", "fácilmente", "rápidamente", "lentamente", "bien", "mal", "mejor", "peor", "arriba", "abajo", "adelante", "atrás", "cerca", "lejos", "dentro", "fuera", "encima", "debajo", "frente", "detrás", "antes", "después", "luego", "pronto", "tarde", "todavía", "ya", "aun", "aún", "quizá"}
437
-
438
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
439
-
440
  if not valid_words:
441
  logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
442
  return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
443
-
444
  word_counts = Counter(valid_words)
445
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
446
-
447
  if not top_keywords:
448
- logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
449
- return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
450
-
451
  logger.info(f"Palabras clave finales: {top_keywords}")
452
  return top_keywords
453
 
454
- # crear_video ahora recibe la voz seleccionada
455
  def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
456
  logger.info("="*80)
457
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
458
  logger.debug(f"Input: '{input_text[:100]}...'")
459
  logger.info(f"Voz seleccionada: {selected_voice}")
460
-
461
  start_time = datetime.now()
462
  temp_dir_intermediate = None
463
-
 
 
464
  audio_tts_original = None
465
  musica_audio_original = None
466
  audio_tts = None
@@ -469,44 +395,32 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
469
  video_final = None
470
  source_clips = []
471
  clips_to_concatenate = []
472
-
473
  try:
474
- # 1. Generar o usar guion
475
  if prompt_type == "Generar Guion con IA":
476
  guion = generate_script(input_text)
477
  else:
478
  guion = input_text.strip()
479
-
480
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
481
-
482
  if not guion.strip():
483
  logger.error("El guion resultante está vacío o solo contiene espacios.")
484
  raise ValueError("El guion está vacío.")
485
-
486
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
487
  logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
488
- temp_intermediate_files = []
489
-
490
- # 2. Generar audio de voz usando la voz seleccionada, con reintentos si falla
491
  logger.info("Generando audio de voz...")
492
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
493
-
494
  tts_voices_to_try = [selected_voice]
495
  fallback_juan = "es-ES-JuanNeural"
496
  fallback_elvira = "es-ES-ElviraNeural"
497
-
498
  if fallback_juan and fallback_juan != selected_voice and fallback_juan not in tts_voices_to_try:
499
  tts_voices_to_try.append(fallback_juan)
500
  if fallback_elvira and fallback_elvira != selected_voice and fallback_elvira not in tts_voices_to_try:
501
  tts_voices_to_try.append(fallback_elvira)
502
-
503
  tts_success = False
504
  tried_voices = set()
505
-
506
  for current_voice in tts_voices_to_try:
507
- if not current_voice or current_voice in tried_voices: continue
 
508
  tried_voices.add(current_voice)
509
-
510
  logger.info(f"Intentando TTS con voz: {current_voice}...")
511
  try:
512
  tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=current_voice))
@@ -514,38 +428,34 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
514
  logger.info(f"TTS exitoso con voz '{current_voice}'.")
515
  break
516
  except Exception as e:
517
- logger.warning(f"Fallo al generar TTS con voz '{current_voice}': {str(e)}", exc_info=True)
518
- pass
519
-
520
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
521
- logger.error("Fallo en la generación de voz después de todos los intentos. Archivo de audio no creado o es muy pequeño.")
522
- raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
523
-
524
  temp_intermediate_files.append(voz_path)
525
-
526
  audio_tts_original = AudioFileClip(voz_path)
527
-
528
  if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
529
  logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0) *después* de crear AudioFileClip.")
530
- try: audio_tts_original.close()
531
- except: pass
 
 
532
  audio_tts_original = None
533
  if os.path.exists(voz_path):
534
- try: os.remove(voz_path)
535
- except: pass
 
 
536
  if voz_path in temp_intermediate_files:
537
- temp_intermediate_files.remove(voz_path)
538
-
539
  raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
540
-
541
  audio_tts = audio_tts_original
542
  audio_duration = audio_tts_original.duration
543
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
544
-
545
  if audio_duration < 1.0:
546
- logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
547
- raise ValueError("Generated voice audio is too short (min 1 second required).")
548
- # 3. Extraer palabras clave
549
  logger.info("Extrayendo palabras clave...")
550
  try:
551
  keywords = extract_visual_keywords_from_script(guion)
@@ -553,18 +463,15 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
553
  except Exception as e:
554
  logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
555
  keywords = ["naturaleza", "paisaje"]
556
-
557
  if not keywords:
558
- keywords = ["video", "background"]
559
-
560
- # 4. Buscar y descargar videos
561
  logger.info("Buscando videos en Pexels...")
562
  videos_data = []
563
  total_desired_videos = 10
564
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
565
-
566
  for keyword in keywords:
567
- if len(videos_data) >= total_desired_videos: break
 
568
  try:
569
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
570
  if videos:
@@ -572,38 +479,34 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
572
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
573
  except Exception as e:
574
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
575
-
576
  if len(videos_data) < total_desired_videos / 2:
577
  logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
578
  generic_keywords = ["mystery", "alien", "ufo", "conspiracy", "paranormal", "supernatural", "horror", "fear", "suspense", "secret", "government", "cover_up", "simulation", "matrix", "apocalypse", "dystopian", "shadow", "occult", "unexplained", "creepy", "extraterrestrial", "abduction", "experiment", "secret_society", "illuminati", "new_world_order", "ancient_aliens", "ufo_sighting", "cryptid", "bigfoot", "loch_ness", "ghost", "haunting", "spirit", "demon", "possession", "exorcism", "witchcraft", "ritual", "cursed", "urban_legend", "myth", "legend", "folklore", "scary", "terror", "panic", "anxiety", "dread", "nightmare", "dark", "gloomy", "fog", "haunted", "cemetery", "asylum", "abandoned", "ruins", "underground", "tunnel", "bunker", "lab", "experiment", "government_secret", "mind_control", "brainwash", "propaganda", "surveillance", "spy", "whistleblower", "leak", "anonymous", "hack", "cyber", "virtual_reality", "ai", "artificial_intelligence", "robot", "cyborg", "apocalyptic", "post_apocalyptic", "zombie", "outbreak", "pandemic", "contagion", "biohazard", "radiation", "nuclear", "doomsday", "armageddon", "revelation", "prophecy", "symbolism", "hidden_meaning", "enigma", "puzzle", "code", "cipher", "mysterious", "unidentified", "anomaly", "glitch", "time_travel", "parallel_universe", "dimension", "portal"]
579
  for keyword in generic_keywords:
580
- if len(videos_data) >= total_desired_videos: break
581
- try:
 
582
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
583
  if videos:
584
  videos_data.extend(videos)
585
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
586
- except Exception as e:
587
  logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
588
-
589
  if not videos_data:
590
  logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
591
  raise ValueError("No se encontraron videos adecuados en Pexels.")
592
-
593
  video_paths = []
594
  logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
595
  for video in videos_data:
596
  if 'video_files' not in video or not video['video_files']:
597
  logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
598
  continue
599
-
600
  try:
601
  best_quality = None
602
  for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
603
  if 'link' in vf:
604
  best_quality = vf
605
  break
606
-
607
  if best_quality and 'link' in best_quality:
608
  path = download_video_file(best_quality['link'], temp_dir_intermediate)
609
  if path:
@@ -611,187 +514,156 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
611
  temp_intermediate_files.append(path)
612
  logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
613
  else:
614
- logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
615
  else:
616
  logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
617
-
618
  except Exception as e:
619
  logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
620
-
621
  logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
622
  if not video_paths:
623
  logger.error("No se pudo descargar ningún archivo de video utilizable.")
624
  raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
625
-
626
- # 5. Procesar y concatenar clips de video
627
  logger.info("Procesando y concatenando videos descargados...")
628
  current_duration = 0
629
  min_clip_duration = 0.5
630
  max_clip_segment = 10.0
631
-
632
  for i, path in enumerate(video_paths):
633
  if current_duration >= audio_duration + max_clip_segment:
634
  logger.debug(f"Video base suficiente ({current_duration:.1f}s >= {audio_duration:.1f}s + {max_clip_segment:.1f}s buffer). Dejando de procesar clips fuente restantes.")
635
  break
636
-
637
  clip = None
638
  try:
639
  logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
640
  clip = VideoFileClip(path)
641
  source_clips.append(clip)
642
-
643
  if clip.reader is None or clip.duration is None or clip.duration <= 0:
644
  logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido (reader is None o duración <= 0). Saltando.")
645
  continue
646
-
647
  remaining_needed = audio_duration - current_duration
648
  potential_use_duration = min(clip.duration, max_clip_segment)
649
-
650
  if remaining_needed > 0:
651
- segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
652
- segment_duration = max(min_clip_duration, segment_duration)
653
- segment_duration = min(segment_duration, clip.duration)
654
-
655
- if segment_duration >= min_clip_duration:
656
- try:
657
- sub = clip.subclip(0, segment_duration)
658
- if sub.reader is None or sub.duration is None or sub.duration <= 0:
659
- logger.warning(f"[{i+1}/{len(video_paths)}] Subclip generado de {path} es inválido. Saltando.")
660
- try: sub.close()
661
- except: pass
662
- continue
663
-
664
- clips_to_concatenate.append(sub)
665
- current_duration += sub.duration
666
- logger.debug(f"[{i+1}/{len(video_paths)}] Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
667
-
668
- except Exception as sub_e:
669
- logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip de {path} ({segment_duration:.1f}s): {str(sub_e)}")
670
- continue
671
- else:
672
- logger.debug(f"[{i+1}/{len(video_paths)}] Clip {path} ({clip.duration:.1f}s) no contribuye un segmento suficiente ({segment_duration:.1f}s necesario). Saltando.")
673
  else:
674
  logger.debug(f"[{i+1}/{len(video_paths)}] Duración de video base ya alcanzada. Saltando clip.")
675
-
676
  except Exception as e:
677
  logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
678
  continue
679
-
680
  logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
681
-
682
  if not clips_to_concatenate:
683
  logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
684
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
685
-
686
  logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
687
  concatenated_base = None
688
  try:
689
  concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
690
  logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
691
-
692
  if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
693
- logger.critical("Video base concatenado es inválido después de la primera concatenación (None o duración cero).")
694
- raise ValueError("Fallo al crear video base válido a partir de segmentos.")
695
-
696
  except Exception as e:
697
- logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
698
- raise ValueError("Fallo durante la concatenación de video inicial.")
699
  finally:
700
- for clip_segment in clips_to_concatenate:
701
- try: clip_segment.close()
702
- except: pass
703
- clips_to_concatenate = []
704
-
 
705
  video_base = concatenated_base
706
-
707
  final_video_base = video_base
708
-
709
  if final_video_base.duration < audio_duration:
710
  logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
711
-
712
  num_full_repeats = int(audio_duration // final_video_base.duration)
713
  remaining_duration = audio_duration % final_video_base.duration
714
-
715
  repeated_clips_list = [final_video_base] * num_full_repeats
716
  if remaining_duration > 0:
717
  try:
718
  remaining_clip = final_video_base.subclip(0, remaining_duration)
719
  if remaining_clip is None or remaining_clip.duration is None or remaining_clip.duration <= 0:
720
  logger.warning(f"Subclip generado para duración restante {remaining_duration:.2f}s es inválido. Saltando.")
721
- try: remaining_clip.close()
722
- except: pass
 
 
723
  else:
724
- repeated_clips_list.append(remaining_clip)
725
- logger.debug(f"Añadiendo segmento restante: {remaining_duration:.2f}s")
726
-
727
  except Exception as e:
728
- logger.warning(f"Error creando subclip para duración restante {remaining_duration:.2f}s: {str(e)}")
729
-
730
  if repeated_clips_list:
731
- logger.info(f"Concatenando {len(repeated_clips_list)} partes para repetición.")
732
- video_base_repeated = None
733
- try:
734
  video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
735
  logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
736
-
737
  if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
738
- logger.critical("Video base repetido concatenado es inválido.")
739
- raise ValueError("Fallo al crear video base repetido válido.")
740
-
741
  if final_video_base is not video_base_repeated:
742
- try: final_video_base.close()
743
- except: pass
744
-
 
745
  final_video_base = video_base_repeated
746
-
747
- except Exception as e:
748
  logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
749
  raise ValueError("Fallo durante la repetición de video.")
750
- finally:
751
- if 'repeated_clips_list' in locals():
752
- for clip in repeated_clips_list:
753
- if clip is not final_video_base:
754
- try: clip.close()
755
- except: pass
756
-
757
-
758
  if final_video_base.duration > audio_duration:
759
- logger.info(f"Recortando video base ({final_video_base.duration:.2f}s) para que coincida con la duración del audio ({audio_duration:.2f}s).")
760
- trimmed_video_base = None
761
- try:
762
  trimmed_video_base = final_video_base.subclip(0, audio_duration)
763
  if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
764
- logger.critical("Video base recortado es inválido.")
765
- raise ValueError("Fallo al crear video base recortado válido.")
766
-
767
  if final_video_base is not trimmed_video_base:
768
- try: final_video_base.close()
769
- except: pass
770
-
 
771
  final_video_base = trimmed_video_base
772
-
773
- except Exception as e:
774
  logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
775
  raise ValueError("Fallo durante el recorte de video.")
776
-
777
-
778
  if final_video_base is None or final_video_base.duration is None or final_video_base.duration <= 0:
779
- logger.critical("Video base final es inválido antes de audio/escritura (None o duración cero).")
780
- raise ValueError("Video base final es inválido.")
781
-
782
  if final_video_base.size is None or final_video_base.size[0] <= 0 or final_video_base.size[1] <= 0:
783
- logger.critical(f"Video base final tiene tamaño inválido: {final_video_base.size}. No se puede escribir video.")
784
- raise ValueError("Video base final tiene tamaño inválido antes de escribir.")
785
-
786
  video_base = final_video_base
787
-
788
- # 6. Manejar música de fondo
789
  logger.info("Procesando audio...")
790
-
791
  final_audio = audio_tts_original
792
-
793
  musica_audio_looped = None
794
-
795
  if musica_file:
796
  musica_audio_original = None
797
  try:
@@ -799,73 +671,70 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
799
  shutil.copyfile(musica_file, music_path)
800
  temp_intermediate_files.append(music_path)
801
  logger.info(f"Música de fondo copiada a: {music_path}")
802
-
803
  musica_audio_original = AudioFileClip(music_path)
804
-
805
  if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
806
- logger.warning("Clip de música de fondo parece inválido o tiene duración cero. Saltando música.")
807
- try: musica_audio_original.close()
808
- except: pass
809
- musica_audio_original = None
 
 
810
  else:
811
- musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
812
- logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
813
-
814
- if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
815
- logger.warning("Clip de música de fondo loopeado es inválido. Saltando música.")
816
- try: musica_audio_looped.close()
817
- except: pass
818
- musica_audio_looped = None
819
-
820
-
821
  if musica_audio_looped:
822
  composite_audio = CompositeAudioClip([
823
- musica_audio_looped.volumex(0.2), # Volumen 20% para música
824
- audio_tts_original.volumex(1.0) # Volumen 100% para voz
825
  ])
826
-
827
  if composite_audio.duration is None or composite_audio.duration <= 0:
828
  logger.warning("Clip de audio compuesto es inválido (None o duración cero). Usando solo audio de voz.")
829
- try: composite_audio.close()
830
- except: pass
 
 
831
  final_audio = audio_tts_original
832
  else:
833
- logger.info("Mezcla de audio completada (voz + música).")
834
- final_audio = composite_audio
835
- musica_audio = musica_audio_looped # Asignar para limpieza
836
-
837
  except Exception as e:
838
  logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
839
  final_audio = audio_tts_original
840
  musica_audio = None
841
  logger.warning("Usando solo audio de voz debido a un error con la música.")
842
-
843
-
844
  if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
845
  logger.warning(f"Duración del audio final ({final_audio.duration:.2f}s) difiere significativamente del video base ({video_base.duration:.2f}s). Intentando recorte.")
846
  try:
847
- if final_audio.duration > video_base.duration:
848
- trimmed_final_audio = final_audio.subclip(0, video_base.duration)
849
- if trimmed_final_audio is None or trimmed_final_audio.duration <= 0:
850
- logger.warning("Audio final recortado es inválido. Usando audio final original.")
851
- try: trimmed_final_audio.close()
852
- except: pass
853
- else:
854
- if final_audio is not trimmed_final_audio:
855
- try: final_audio.close()
856
- except: pass
857
- final_audio = trimmed_final_audio
858
- logger.warning("Audio final recortado para que coincida con la duración del video.")
 
 
 
 
859
  except Exception as e:
860
- logger.warning(f"Error ajustando duración del audio final: {str(e)}")
861
-
862
- try:
863
-
864
- output_filename = f"video_{int(time.time())}.mp4" # Nombre único con timestamp
865
  output_path = os.path.join(temp_dir_intermediate, output_filename)
866
  permanent_path = f"/tmp/{output_filename}"
867
-
868
- # Escribir el video
869
  video_final.write_videofile(
870
  output_path,
871
  fps=24,
@@ -879,149 +748,119 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
879
  ],
880
  logger='bar'
881
  )
882
-
883
- # Mover a ubicación permanente en /tmp
884
  try:
885
- shutil.copy(output_path, permanent_path) # Usamos copy() en lugar de move()
886
  logger.info(f"Video guardado permanentemente en: {permanent_path}")
887
  except Exception as move_error:
888
  logger.error(f"Error moviendo archivo: {str(move_error)}. Usando path original.")
889
  permanent_path = output_path
890
-
891
- # Cierra los clips para liberar memoria
892
  try:
893
  video_final.close()
894
  if 'video_base' in locals() and video_base is not None and video_base is not video_final:
895
  video_base.close()
896
  except Exception as close_error:
897
  logger.error(f"Error cerrando clips: {str(close_error)}")
898
-
899
  total_time = (datetime.now() - start_time).total_seconds()
900
  logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {permanent_path} | Tiempo total: {total_time:.2f}s")
901
-
902
  return permanent_path
903
-
904
  except ValueError as ve:
905
- logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
906
- raise ve
907
  except Exception as e:
908
  logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
909
  raise e
910
  finally:
911
  logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
912
-
913
  for clip in source_clips:
914
  try:
915
  clip.close()
916
  except Exception as e:
917
  logger.warning(f"Error cerrando clip de video fuente en finally: {str(e)}")
918
-
919
  for clip_segment in clips_to_concatenate:
920
- try:
921
- clip_segment.close()
922
- except Exception as e:
923
- logger.warning(f"Error cerrando segmento de video en finally: {str(e)}")
924
-
925
  if musica_audio is not None:
926
  try:
927
  musica_audio.close()
928
  except Exception as e:
929
  logger.warning(f"Error cerrando musica_audio (procesada) en finally: {str(e)}")
930
-
931
  if musica_audio_original is not None and musica_audio_original is not musica_audio:
932
- try:
933
- musica_audio_original.close()
934
- except Exception as e:
935
- logger.warning(f"Error cerrando musica_audio_original en finally: {str(e)}")
936
-
937
  if audio_tts is not None and audio_tts is not audio_tts_original:
938
- try:
939
- audio_tts.close()
940
- except Exception as e:
941
- logger.warning(f"Error cerrando audio_tts (procesada) en finally: {str(e)}")
942
-
943
  if audio_tts_original is not None:
944
- try:
945
- audio_tts_original.close()
946
- except Exception as e:
947
- logger.warning(f"Error cerrando audio_tts_original en finally: {str(e)}")
948
-
949
  if video_final is not None:
950
  try:
951
  video_final.close()
952
  except Exception as e:
953
  logger.warning(f"Error cerrando video_final en finally: {str(e)}")
954
  elif video_base is not None and video_base is not video_final:
955
- try:
956
- video_base.close()
957
- except Exception as e:
958
- logger.warning(f"Error cerrando video_base en finally: {str(e)}")
959
-
960
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
961
- final_output_in_temp = os.path.join(temp_dir_intermediate, output_filename)
962
-
963
- for path in temp_intermediate_files:
964
- try:
965
- if os.path.isfile(path) and path != final_output_in_temp and path != permanent_path:
966
- logger.debug(f"Eliminando archivo temporal intermedio: {path}")
967
- os.remove(path)
968
- elif os.path.isfile(path) and (path == final_output_in_temp or path == permanent_path):
969
- logger.debug(f"Saltando eliminación del archivo de video final: {path}")
970
- except Exception as e:
971
- logger.warning(f"No se pudo eliminar archivo temporal intermedio {path}: {str(e)}")
972
-
973
- logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
974
 
975
- # run_app ahora recibe todos los inputs, incluyendo la voz seleccionada
976
- def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice): # <-- Recibe el valor del Dropdown
977
  logger.info("="*80)
978
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
979
-
980
- # Elegir el texto de entrada basado en el prompt_type
981
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
982
-
983
  output_video = None
984
  output_file = None
985
  status_msg = gr.update(value="⏳ Procesando...", interactive=False)
986
-
987
  if not input_text or not input_text.strip():
988
  logger.warning("Texto de entrada vacío.")
989
- # Retornar None para video y archivo, actualizar estado con mensaje de error
990
  return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
991
-
992
- # Validar la voz seleccionada. Si no es válida, usar la por defecto.
993
- # AVAILABLE_VOICES se obtiene al inicio. Hay que buscar si el voice_id existe en la lista de pares (nombre, id)
994
  voice_ids_disponibles = [v[1] for v in AVAILABLE_VOICES]
995
  if selected_voice not in voice_ids_disponibles:
996
  logger.warning(f"Voz seleccionada inválida o no encontrada en la lista: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}.")
997
- selected_voice = DEFAULT_VOICE_ID # <-- Usar el ID de la voz por defecto
998
  else:
999
  logger.info(f"Voz seleccionada validada: {selected_voice}")
1000
-
1001
-
1002
  logger.info(f"Tipo de entrada: {prompt_type}")
1003
  logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
1004
  if musica_file:
1005
  logger.info(f"Archivo de música recibido: {musica_file}")
1006
  else:
1007
  logger.info("No se proporcionó archivo de música.")
1008
- logger.info(f"Voz final a usar (ID): {selected_voice}") # Loguear el ID de la voz final
1009
-
1010
  try:
1011
  logger.info("Llamando a crear_video...")
1012
- # Pasar el input_text elegido, la voz seleccionada (el ID) y el archivo de música a crear_video
1013
- video_path = crear_video(prompt_type, input_text, selected_voice, musica_file) # <-- PASAR selected_voice (ID) a crear_video
1014
-
1015
  if video_path and os.path.exists(video_path):
1016
  logger.info(f"crear_video retornó path: {video_path}")
1017
  logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
1018
- output_video = video_path # Establecer valor del componente de video
1019
- output_file = video_path # Establecer valor del componente de archivo para descarga
1020
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
1021
  else:
1022
  logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
1023
  status_msg = gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
1024
-
1025
  except ValueError as ve:
1026
  logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
1027
  status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
@@ -1032,16 +871,12 @@ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
1032
  logger.info("Fin del handler run_app.")
1033
  return output_video, output_file, status_msg
1034
 
1035
-
1036
- # Interfaz de Gradio
1037
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
1038
  .gradio-container {max-width: 800px; margin: auto;}
1039
  h1 {text-align: center;}
1040
  """) as app:
1041
-
1042
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
1043
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
1044
-
1045
  with gr.Row():
1046
  with gr.Column():
1047
  prompt_type = gr.Radio(
@@ -1049,8 +884,6 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
1049
  label="Método de Entrada",
1050
  value="Generar Guion con IA"
1051
  )
1052
-
1053
- # Contenedores para los campos de texto para controlar la visibilidad
1054
  with gr.Column(visible=True) as ia_guion_column:
1055
  prompt_ia = gr.Textbox(
1056
  label="Tema para IA",
@@ -1059,7 +892,6 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
1059
  max_lines=4,
1060
  value=""
1061
  )
1062
-
1063
  with gr.Column(visible=False) as manual_guion_column:
1064
  prompt_manual = gr.Textbox(
1065
  label="Tu Guion Completo",
@@ -1068,25 +900,19 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
1068
  max_lines=10,
1069
  value=""
1070
  )
1071
-
1072
  musica_input = gr.Audio(
1073
  label="Música de fondo (opcional)",
1074
  type="filepath",
1075
  interactive=True,
1076
  value=None
1077
  )
1078
-
1079
- # --- COMPONENTE: Selección de Voz ---
1080
  voice_dropdown = gr.Dropdown(
1081
  label="Seleccionar Voz para Guion",
1082
  choices=AVAILABLE_VOICES,
1083
  value=DEFAULT_VOICE_ID,
1084
  interactive=True
1085
  )
1086
- # --- FIN COMPONENTE ---
1087
-
1088
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
1089
-
1090
  with gr.Column():
1091
  video_output = gr.Video(
1092
  label="Previsualización del Video Generado",
@@ -1105,16 +931,12 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
1105
  placeholder="Esperando acción...",
1106
  value="Esperando entrada..."
1107
  )
1108
-
1109
- # Evento para mostrar/ocultar los campos de texto según el tipo de prompt
1110
  prompt_type.change(
1111
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
1112
  gr.update(visible=x == "Usar Mi Guion")),
1113
  inputs=prompt_type,
1114
  outputs=[ia_guion_column, manual_guion_column]
1115
  )
1116
-
1117
- # Evento click del botón de generar video
1118
  generate_btn.click(
1119
  lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
1120
  outputs=[video_output, file_output, status_output],
@@ -1128,7 +950,6 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
1128
  inputs=[video_output, file_output, status_output],
1129
  outputs=[file_output]
1130
  )
1131
-
1132
  gr.Markdown("### Instrucciones:")
1133
  gr.Markdown("""
1134
  1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
@@ -1154,11 +975,8 @@ if __name__ == "__main__":
1154
  except Exception as e:
1155
  logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
1156
  except Exception as e:
1157
- logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
1158
-
1159
- # Solución para el timeout de Gradio
1160
- os.environ['GRADIO_SERVER_TIMEOUT'] = '1200' # 1200 segundos = 10 minutos
1161
-
1162
  logger.info("Iniciando aplicación Gradio...")
1163
  try:
1164
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
9
  import torch
10
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
11
  from keybert import KeyBERT
 
12
  from moviepy.editor import VideoFileClip, concatenate_videoclips, AudioFileClip, CompositeAudioClip, concatenate_audioclips, AudioClip
13
  import re
14
  import math
15
  import shutil
16
  import json
17
  from collections import Counter
18
+ import time
19
 
 
20
  logging.basicConfig(
21
  level=logging.DEBUG,
22
  format='%(asctime)s - %(levelname)s - %(message)s',
 
30
  logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
31
  logger.info("="*80)
32
 
 
 
33
  VOCES_DISPONIBLES = {
34
  "Español (España)": {
35
  "es-ES-JuanNeural": "Juan (España) - Masculino",
 
92
  }
93
  }
94
 
 
95
  def get_voice_choices():
96
  choices = []
97
  for region, voices in VOCES_DISPONIBLES.items():
98
  for voice_id, voice_name in voices.items():
 
99
  choices.append((f"{voice_name} ({region})", voice_id))
100
  return choices
101
 
102
+ AVAILABLE_VOICES = get_voice_choices()
103
+ DEFAULT_VOICE_ID = "es-ES-JuanNeural"
 
 
 
 
 
 
 
104
  DEFAULT_VOICE_NAME = DEFAULT_VOICE_ID
105
  for text, voice_id in AVAILABLE_VOICES:
106
  if voice_id == DEFAULT_VOICE_ID:
107
  DEFAULT_VOICE_NAME = text
108
  break
 
109
  if DEFAULT_VOICE_ID not in [v[1] for v in AVAILABLE_VOICES]:
110
  DEFAULT_VOICE_ID = AVAILABLE_VOICES[0][1] if AVAILABLE_VOICES else "en-US-AriaNeural"
111
+ DEFAULT_VOICE_NAME = AVAILABLE_VOICES[0][0] if AVAILABLE_VOICES else "Aria (United States) - Female"
112
 
113
  logger.info(f"Voz por defecto seleccionada (ID): {DEFAULT_VOICE_ID}")
114
 
 
 
115
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
116
  if not PEXELS_API_KEY:
117
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
 
118
 
 
119
  MODEL_NAME = "datificate/gpt2-small-spanish"
120
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
121
  tokenizer = None
 
143
  if not api_key:
144
  logger.warning("No se puede buscar en Pexels: API Key no configurada.")
145
  return []
 
146
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
147
  headers = {"Authorization": api_key}
148
  try:
 
152
  "orientation": "landscape",
153
  "size": "medium"
154
  }
 
155
  response = requests.get(
156
  "https://api.pexels.com/videos/search",
157
  headers=headers,
 
159
  timeout=20
160
  )
161
  response.raise_for_status()
 
162
  data = response.json()
163
  videos = data.get('videos', [])
164
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
165
  return videos
 
166
  except requests.exceptions.RequestException as e:
167
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
168
  except json.JSONDecodeError:
169
  logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
170
  except Exception as e:
171
  logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
 
172
  return []
173
 
174
  def generate_script(prompt, max_length=150):
 
176
  if not tokenizer or not model:
177
  logger.warning("Modelos GPT-2 no disponibles - Usando prompt original como guion.")
178
  return prompt.strip()
 
179
  instruction_phrase_start = "Escribe un guion corto, interesante y coherente sobre:"
180
  ai_prompt = f"{instruction_phrase_start} {prompt}"
 
181
  try:
182
  inputs = tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=512)
183
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
184
  model.to(device)
185
  inputs = {k: v.to(device) for k, v in inputs.items()}
 
186
  outputs = model.generate(
187
  **inputs,
188
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
 
195
  eos_token_id=tokenizer.eos_token_id,
196
  no_repeat_ngram_size=3
197
  )
 
198
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
 
199
  cleaned_text = text.strip()
 
200
  try:
 
201
  prompt_in_output_idx = text.lower().find(prompt.lower())
202
  if prompt_in_output_idx != -1:
 
203
  cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
204
  logger.debug("Texto limpiado tomando parte después del prompt original.")
205
  else:
206
+ instruction_start_idx = text.find(instruction_phrase_start)
207
+ if instruction_start_idx != -1:
208
+ cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
209
+ logger.debug("Texto limpiado tomando parte después de la frase de instrucción base.")
210
+ else:
211
+ logger.warning("No se pudo identificar el inicio del guión generado. Usando texto generado completo.")
212
+ cleaned_text = text.strip()
 
 
 
 
 
213
  except Exception as e:
214
+ logger.warning(f"Error durante la limpieza heurística del guión de IA: {e}. Usando texto generado sin limpieza adicional.")
215
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
216
+ if not cleaned_text or len(cleaned_text) < 10:
217
+ logger.warning("El guión generado parece muy corto o vacío después de la limpieza heurística. Usando el texto generado original (sin limpieza adicional).")
218
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
 
 
 
 
219
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
220
+ cleaned_text = cleaned_text.lstrip(':').strip()
221
+ cleaned_text = cleaned_text.lstrip('.').strip()
 
 
 
222
  sentences = cleaned_text.split('.')
223
  if sentences and sentences[0].strip():
224
  final_text = sentences[0].strip() + '.'
225
+ if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7:
226
+ final_text += " " + sentences[1].strip() + "."
227
+ final_text = final_text.replace("..", ".")
 
 
228
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
229
  return final_text.strip()
 
230
  logger.info(f"Guion generado final (sin oraciones completas detectadas - Truncado): '{cleaned_text[:100]}...'")
231
+ return cleaned_text.strip()
 
232
  except Exception as e:
233
  logger.error(f"Error generando guion con GPT-2 (fuera del bloque de limpieza): {str(e)}", exc_info=True)
234
  logger.warning("Usando prompt original como guion debido al error de generación.")
235
  return prompt.strip()
236
 
 
237
  async def text_to_speech(text, output_path, voice):
238
  logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice} | Salida: {output_path}")
239
  if not text or not text.strip():
240
  logger.warning("Texto vacío para TTS")
241
  return False
 
242
  try:
243
  communicate = edge_tts.Communicate(text, voice)
244
  await communicate.save(output_path)
 
245
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
246
  logger.info(f"Audio guardado exitosamente en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
247
  return True
248
  else:
249
  logger.error(f"TTS guardó un archivo pequeño o vacío en: {output_path}")
250
  return False
 
251
  except Exception as e:
252
  logger.error(f"Error en TTS con voz '{voice}': {str(e)}", exc_info=True)
253
  return False
 
256
  if not url:
257
  logger.warning("URL de video no proporcionada para descargar")
258
  return None
 
259
  try:
260
  logger.info(f"Descargando video desde: {url[:80]}...")
261
  os.makedirs(temp_dir, exist_ok=True)
262
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
263
  output_path = os.path.join(temp_dir, file_name)
 
264
  with requests.get(url, stream=True, timeout=60) as r:
265
  r.raise_for_status()
266
  with open(output_path, 'wb') as f:
267
  for chunk in r.iter_content(chunk_size=8192):
268
  f.write(chunk)
 
269
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
270
+ logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
271
+ return output_path
272
  else:
273
+ logger.warning(f"Descarga parece incompleta o vacía para {url[:80]}... Archivo: {output_path} Tamaño: {os.path.getsize(output_path) if os.path.exists(output_path) else 'N/A'} bytes")
274
+ if os.path.exists(output_path):
275
+ os.remove(output_path)
276
+ return None
 
277
  except requests.exceptions.RequestException as e:
278
  logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
279
  except Exception as e:
280
  logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
 
281
  return None
282
 
283
  def loop_audio_to_length(audio_clip, target_duration):
284
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
 
285
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
286
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
287
  try:
288
  sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
289
+ return AudioClip(lambda t: 0, duration=target_duration, fps=sr)
290
  except Exception as e:
291
+ logger.error(f"Could not create silence clip: {e}", exc_info=True)
292
+ return AudioFileClip(filename="")
 
293
  if audio_clip.duration >= target_duration:
294
  logger.debug("Audio clip already longer or equal to target. Trimming.")
295
  trimmed_clip = audio_clip.subclip(0, target_duration)
296
  if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
297
+ logger.error("Trimmed audio clip is invalid.")
298
+ try:
299
+ trimmed_clip.close()
300
+ except:
301
+ pass
302
+ return AudioFileClip(filename="")
303
  return trimmed_clip
 
304
  loops = math.ceil(target_duration / audio_clip.duration)
305
  logger.debug(f"Creando {loops} loops de audio")
 
306
  audio_segments = [audio_clip] * loops
307
  looped_audio = None
308
  final_looped_audio = None
309
  try:
310
+ looped_audio = concatenate_audioclips(audio_segments)
311
+ if looped_audio.duration is None or looped_audio.duration <= 0:
 
312
  logger.error("Concatenated audio clip is invalid (None or zero duration).")
313
  raise ValueError("Invalid concatenated audio.")
314
+ final_looped_audio = looped_audio.subclip(0, target_duration)
315
+ if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
 
 
316
  logger.error("Final subclipped audio clip is invalid (None or zero duration).")
317
  raise ValueError("Invalid final subclipped audio.")
318
+ return final_looped_audio
 
 
319
  except Exception as e:
320
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
321
  try:
322
+ if audio_clip.duration is not None and audio_clip.duration > 0:
323
+ logger.warning("Returning original audio clip (may be too short).")
324
+ return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
325
  except:
326
+ pass
327
  logger.error("Fallback to original audio clip failed.")
328
  return AudioFileClip(filename="")
 
329
  finally:
330
  if looped_audio is not None and looped_audio is not final_looped_audio:
331
+ try:
332
+ looped_audio.close()
333
+ except:
334
+ pass
335
 
336
  def extract_visual_keywords_from_script(script_text):
337
  logger.info("Extrayendo palabras clave del guion")
338
  if not script_text or not script_text.strip():
339
  logger.warning("Guion vacío, no se pueden extraer palabras clave.")
340
  return ["naturaleza", "ciudad", "paisaje"]
 
341
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
342
  keywords_list = []
 
343
  if kw_model:
344
  try:
345
  logger.debug("Intentando extracción con KeyBERT...")
346
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
347
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
 
348
  all_keywords = keywords1 + keywords2
349
  all_keywords.sort(key=lambda item: item[1], reverse=True)
 
350
  seen_keywords = set()
351
  for keyword, score in all_keywords:
352
  formatted_keyword = keyword.lower().replace(" ", "+")
 
355
  seen_keywords.add(formatted_keyword)
356
  if len(keywords_list) >= 5:
357
  break
 
358
  if keywords_list:
359
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
360
  return keywords_list
 
361
  except Exception as e:
362
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
 
363
  logger.debug("Extrayendo palabras clave con método simple...")
364
  words = clean_text.lower().split()
365
  stop_words = {"el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con", "para", "del", "al", "por", "su", "sus", "se", "lo", "le", "me", "te", "nos", "os", "les", "mi", "tu",
366
  "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
367
  "nuestros", "vuestros", "estas", "esas", "aquellas", "si", "no", "más", "menos", "sin", "sobre", "bajo", "entre", "hasta", "desde", "durante", "mediante", "según", "versus", "via", "cada", "todo", "todos", "toda", "todas", "poco", "pocos", "poca", "pocas", "mucho", "muchos", "mucha", "muchas", "varios", "varias", "otro", "otros", "otra", "otras", "mismo", "misma", "mismos", "mismas", "tan", "tanto", "tanta", "tantos", "tantas", "tal", "tales", "cual", "cuales", "cuyo", "cuya", "cuyos", "cuyas", "quien", "quienes", "cuan", "cuanto", "cuanta", "cuantos", "cuantas", "como", "donde", "cuando", "porque", "aunque", "mientras", "siempre", "nunca", "jamás", "muy", "casi", "solo", "solamente", "incluso", "apenas", "quizás", "tal vez", "acaso", "claro", "cierto", "obvio", "evidentemente", "realmente", "simplemente", "generalmente", "especialmente", "principalmente", "posiblemente", "probablemente", "difícilmente", "fácilmente", "rápidamente", "lentamente", "bien", "mal", "mejor", "peor", "arriba", "abajo", "adelante", "atrás", "cerca", "lejos", "dentro", "fuera", "encima", "debajo", "frente", "detrás", "antes", "después", "luego", "pronto", "tarde", "todavía", "ya", "aun", "aún", "quizá"}
 
368
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
 
369
  if not valid_words:
370
  logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
371
  return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
 
372
  word_counts = Counter(valid_words)
373
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
 
374
  if not top_keywords:
375
+ logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
376
+ return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
 
377
  logger.info(f"Palabras clave finales: {top_keywords}")
378
  return top_keywords
379
 
 
380
  def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
381
  logger.info("="*80)
382
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
383
  logger.debug(f"Input: '{input_text[:100]}...'")
384
  logger.info(f"Voz seleccionada: {selected_voice}")
 
385
  start_time = datetime.now()
386
  temp_dir_intermediate = None
387
+ output_filename = None
388
+ permanent_path = None
389
+ temp_intermediate_files = []
390
  audio_tts_original = None
391
  musica_audio_original = None
392
  audio_tts = None
 
395
  video_final = None
396
  source_clips = []
397
  clips_to_concatenate = []
 
398
  try:
 
399
  if prompt_type == "Generar Guion con IA":
400
  guion = generate_script(input_text)
401
  else:
402
  guion = input_text.strip()
 
403
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
 
404
  if not guion.strip():
405
  logger.error("El guion resultante está vacío o solo contiene espacios.")
406
  raise ValueError("El guion está vacío.")
 
407
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
408
  logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
 
 
 
409
  logger.info("Generando audio de voz...")
410
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
 
411
  tts_voices_to_try = [selected_voice]
412
  fallback_juan = "es-ES-JuanNeural"
413
  fallback_elvira = "es-ES-ElviraNeural"
 
414
  if fallback_juan and fallback_juan != selected_voice and fallback_juan not in tts_voices_to_try:
415
  tts_voices_to_try.append(fallback_juan)
416
  if fallback_elvira and fallback_elvira != selected_voice and fallback_elvira not in tts_voices_to_try:
417
  tts_voices_to_try.append(fallback_elvira)
 
418
  tts_success = False
419
  tried_voices = set()
 
420
  for current_voice in tts_voices_to_try:
421
+ if not current_voice or current_voice in tried_voices:
422
+ continue
423
  tried_voices.add(current_voice)
 
424
  logger.info(f"Intentando TTS con voz: {current_voice}...")
425
  try:
426
  tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=current_voice))
 
428
  logger.info(f"TTS exitoso con voz '{current_voice}'.")
429
  break
430
  except Exception as e:
431
+ logger.warning(f"Fallo al generar TTS con voz '{current_voice}': {str(e)}", exc_info=True)
432
+ pass
 
433
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
434
+ logger.error("Fallo en la generación de voz después de todos los intentos. Archivo de audio no creado o es muy pequeño.")
435
+ raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
 
436
  temp_intermediate_files.append(voz_path)
 
437
  audio_tts_original = AudioFileClip(voz_path)
 
438
  if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
439
  logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0) *después* de crear AudioFileClip.")
440
+ try:
441
+ audio_tts_original.close()
442
+ except:
443
+ pass
444
  audio_tts_original = None
445
  if os.path.exists(voz_path):
446
+ try:
447
+ os.remove(voz_path)
448
+ except:
449
+ pass
450
  if voz_path in temp_intermediate_files:
451
+ temp_intermediate_files.remove(voz_path)
 
452
  raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
 
453
  audio_tts = audio_tts_original
454
  audio_duration = audio_tts_original.duration
455
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
 
456
  if audio_duration < 1.0:
457
+ logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
458
+ raise ValueError("Generated voice audio is too short (min 1 second required).")
 
459
  logger.info("Extrayendo palabras clave...")
460
  try:
461
  keywords = extract_visual_keywords_from_script(guion)
 
463
  except Exception as e:
464
  logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
465
  keywords = ["naturaleza", "paisaje"]
 
466
  if not keywords:
467
+ keywords = ["video", "background"]
 
 
468
  logger.info("Buscando videos en Pexels...")
469
  videos_data = []
470
  total_desired_videos = 10
471
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
 
472
  for keyword in keywords:
473
+ if len(videos_data) >= total_desired_videos:
474
+ break
475
  try:
476
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
477
  if videos:
 
479
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
480
  except Exception as e:
481
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
 
482
  if len(videos_data) < total_desired_videos / 2:
483
  logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
484
  generic_keywords = ["mystery", "alien", "ufo", "conspiracy", "paranormal", "supernatural", "horror", "fear", "suspense", "secret", "government", "cover_up", "simulation", "matrix", "apocalypse", "dystopian", "shadow", "occult", "unexplained", "creepy", "extraterrestrial", "abduction", "experiment", "secret_society", "illuminati", "new_world_order", "ancient_aliens", "ufo_sighting", "cryptid", "bigfoot", "loch_ness", "ghost", "haunting", "spirit", "demon", "possession", "exorcism", "witchcraft", "ritual", "cursed", "urban_legend", "myth", "legend", "folklore", "scary", "terror", "panic", "anxiety", "dread", "nightmare", "dark", "gloomy", "fog", "haunted", "cemetery", "asylum", "abandoned", "ruins", "underground", "tunnel", "bunker", "lab", "experiment", "government_secret", "mind_control", "brainwash", "propaganda", "surveillance", "spy", "whistleblower", "leak", "anonymous", "hack", "cyber", "virtual_reality", "ai", "artificial_intelligence", "robot", "cyborg", "apocalyptic", "post_apocalyptic", "zombie", "outbreak", "pandemic", "contagion", "biohazard", "radiation", "nuclear", "doomsday", "armageddon", "revelation", "prophecy", "symbolism", "hidden_meaning", "enigma", "puzzle", "code", "cipher", "mysterious", "unidentified", "anomaly", "glitch", "time_travel", "parallel_universe", "dimension", "portal"]
485
  for keyword in generic_keywords:
486
+ if len(videos_data) >= total_desired_videos:
487
+ break
488
+ try:
489
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
490
  if videos:
491
  videos_data.extend(videos)
492
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
493
+ except Exception as e:
494
  logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
 
495
  if not videos_data:
496
  logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
497
  raise ValueError("No se encontraron videos adecuados en Pexels.")
 
498
  video_paths = []
499
  logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
500
  for video in videos_data:
501
  if 'video_files' not in video or not video['video_files']:
502
  logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
503
  continue
 
504
  try:
505
  best_quality = None
506
  for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
507
  if 'link' in vf:
508
  best_quality = vf
509
  break
 
510
  if best_quality and 'link' in best_quality:
511
  path = download_video_file(best_quality['link'], temp_dir_intermediate)
512
  if path:
 
514
  temp_intermediate_files.append(path)
515
  logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
516
  else:
517
+ logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
518
  else:
519
  logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
 
520
  except Exception as e:
521
  logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
 
522
  logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
523
  if not video_paths:
524
  logger.error("No se pudo descargar ningún archivo de video utilizable.")
525
  raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
 
 
526
  logger.info("Procesando y concatenando videos descargados...")
527
  current_duration = 0
528
  min_clip_duration = 0.5
529
  max_clip_segment = 10.0
 
530
  for i, path in enumerate(video_paths):
531
  if current_duration >= audio_duration + max_clip_segment:
532
  logger.debug(f"Video base suficiente ({current_duration:.1f}s >= {audio_duration:.1f}s + {max_clip_segment:.1f}s buffer). Dejando de procesar clips fuente restantes.")
533
  break
 
534
  clip = None
535
  try:
536
  logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
537
  clip = VideoFileClip(path)
538
  source_clips.append(clip)
 
539
  if clip.reader is None or clip.duration is None or clip.duration <= 0:
540
  logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido (reader is None o duración <= 0). Saltando.")
541
  continue
 
542
  remaining_needed = audio_duration - current_duration
543
  potential_use_duration = min(clip.duration, max_clip_segment)
 
544
  if remaining_needed > 0:
545
+ segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
546
+ segment_duration = max(min_clip_duration, segment_duration)
547
+ segment_duration = min(segment_duration, clip.duration)
548
+ if segment_duration >= min_clip_duration:
549
+ try:
550
+ sub = clip.subclip(0, segment_duration)
551
+ if sub.reader is None or sub.duration is None or sub.duration <= 0:
552
+ logger.warning(f"[{i+1}/{len(video_paths)}] Subclip generado de {path} es inválido. Saltando.")
553
+ try:
554
+ sub.close()
555
+ except:
556
+ pass
557
+ continue
558
+ clips_to_concatenate.append(sub)
559
+ current_duration += sub.duration
560
+ logger.debug(f"[{i+1}/{len(video_paths)}] Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
561
+ except Exception as sub_e:
562
+ logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip de {path} ({segment_duration:.1f}s): {str(sub_e)}")
563
+ continue
564
+ else:
565
+ logger.debug(f"[{i+1}/{len(video_paths)}] Clip {path} ({clip.duration:.1f}s) no contribuye un segmento suficiente ({segment_duration:.1f}s necesario). Saltando.")
 
566
  else:
567
  logger.debug(f"[{i+1}/{len(video_paths)}] Duración de video base ya alcanzada. Saltando clip.")
 
568
  except Exception as e:
569
  logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
570
  continue
 
571
  logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
 
572
  if not clips_to_concatenate:
573
  logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
574
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
 
575
  logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
576
  concatenated_base = None
577
  try:
578
  concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
579
  logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
 
580
  if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
581
+ logger.critical("Video base concatenado es inválido después de la primera concatenación (None o duración cero).")
582
+ raise ValueError("Fallo al crear video base válido a partir de segmentos.")
 
583
  except Exception as e:
584
+ logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
585
+ raise ValueError("Fallo durante la concatenación de video inicial.")
586
  finally:
587
+ for clip_segment in clips_to_concatenate:
588
+ try:
589
+ clip_segment.close()
590
+ except:
591
+ pass
592
+ clips_to_concatenate = []
593
  video_base = concatenated_base
 
594
  final_video_base = video_base
 
595
  if final_video_base.duration < audio_duration:
596
  logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
 
597
  num_full_repeats = int(audio_duration // final_video_base.duration)
598
  remaining_duration = audio_duration % final_video_base.duration
 
599
  repeated_clips_list = [final_video_base] * num_full_repeats
600
  if remaining_duration > 0:
601
  try:
602
  remaining_clip = final_video_base.subclip(0, remaining_duration)
603
  if remaining_clip is None or remaining_clip.duration is None or remaining_clip.duration <= 0:
604
  logger.warning(f"Subclip generado para duración restante {remaining_duration:.2f}s es inválido. Saltando.")
605
+ try:
606
+ remaining_clip.close()
607
+ except:
608
+ pass
609
  else:
610
+ repeated_clips_list.append(remaining_clip)
611
+ logger.debug(f"Añadiendo segmento restante: {remaining_duration:.2f}s")
 
612
  except Exception as e:
613
+ logger.warning(f"Error creando subclip para duración restante {remaining_duration:.2f}s: {str(e)}")
 
614
  if repeated_clips_list:
615
+ logger.info(f"Concatenando {len(repeated_clips_list)} partes para repetición.")
616
+ video_base_repeated = None
617
+ try:
618
  video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
619
  logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
 
620
  if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
621
+ logger.critical("Video base repetido concatenado es inválido.")
622
+ raise ValueError("Fallo al crear video base repetido válido.")
 
623
  if final_video_base is not video_base_repeated:
624
+ try:
625
+ final_video_base.close()
626
+ except:
627
+ pass
628
  final_video_base = video_base_repeated
629
+ except Exception as e:
 
630
  logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
631
  raise ValueError("Fallo durante la repetición de video.")
632
+ finally:
633
+ if 'repeated_clips_list' in locals():
634
+ for clip in repeated_clips_list:
635
+ if clip is not final_video_base:
636
+ try:
637
+ clip.close()
638
+ except:
639
+ pass
640
  if final_video_base.duration > audio_duration:
641
+ logger.info(f"Recortando video base ({final_video_base.duration:.2f}s) para que coincida con la duración del audio ({audio_duration:.2f}s).")
642
+ trimmed_video_base = None
643
+ try:
644
  trimmed_video_base = final_video_base.subclip(0, audio_duration)
645
  if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
646
+ logger.critical("Video base recortado es inválido.")
647
+ raise ValueError("Fallo al crear video base recortado válido.")
 
648
  if final_video_base is not trimmed_video_base:
649
+ try:
650
+ final_video_base.close()
651
+ except:
652
+ pass
653
  final_video_base = trimmed_video_base
654
+ except Exception as e:
 
655
  logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
656
  raise ValueError("Fallo durante el recorte de video.")
 
 
657
  if final_video_base is None or final_video_base.duration is None or final_video_base.duration <= 0:
658
+ logger.critical("Video base final es inválido antes de audio/escritura (None o duración cero).")
659
+ raise ValueError("Video base final es inválido.")
 
660
  if final_video_base.size is None or final_video_base.size[0] <= 0 or final_video_base.size[1] <= 0:
661
+ logger.critical(f"Video base final tiene tamaño inválido: {final_video_base.size}. No se puede escribir video.")
662
+ raise ValueError("Video base final tiene tamaño inválido antes de escribir.")
 
663
  video_base = final_video_base
 
 
664
  logger.info("Procesando audio...")
 
665
  final_audio = audio_tts_original
 
666
  musica_audio_looped = None
 
667
  if musica_file:
668
  musica_audio_original = None
669
  try:
 
671
  shutil.copyfile(musica_file, music_path)
672
  temp_intermediate_files.append(music_path)
673
  logger.info(f"Música de fondo copiada a: {music_path}")
 
674
  musica_audio_original = AudioFileClip(music_path)
 
675
  if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
676
+ logger.warning("Clip de música de fondo parece inválido o tiene duración cero. Saltando música.")
677
+ try:
678
+ musica_audio_original.close()
679
+ except:
680
+ pass
681
+ musica_audio_original = None
682
  else:
683
+ musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
684
+ logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
685
+ if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
686
+ logger.warning("Clip de música de fondo loopeado es inválido. Saltando música.")
687
+ try:
688
+ musica_audio_looped.close()
689
+ except:
690
+ pass
691
+ musica_audio_looped = None
 
692
  if musica_audio_looped:
693
  composite_audio = CompositeAudioClip([
694
+ musica_audio_looped.volumex(0.2),
695
+ audio_tts_original.volumex(1.0)
696
  ])
 
697
  if composite_audio.duration is None or composite_audio.duration <= 0:
698
  logger.warning("Clip de audio compuesto es inválido (None o duración cero). Usando solo audio de voz.")
699
+ try:
700
+ composite_audio.close()
701
+ except:
702
+ pass
703
  final_audio = audio_tts_original
704
  else:
705
+ logger.info("Mezcla de audio completada (voz + música).")
706
+ final_audio = composite_audio
707
+ musica_audio = musica_audio_looped
 
708
  except Exception as e:
709
  logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
710
  final_audio = audio_tts_original
711
  musica_audio = None
712
  logger.warning("Usando solo audio de voz debido a un error con la música.")
 
 
713
  if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
714
  logger.warning(f"Duración del audio final ({final_audio.duration:.2f}s) difiere significativamente del video base ({video_base.duration:.2f}s). Intentando recorte.")
715
  try:
716
+ if final_audio.duration > video_base.duration:
717
+ trimmed_final_audio = final_audio.subclip(0, video_base.duration)
718
+ if trimmed_final_audio is None or trimmed_final_audio.duration <= 0:
719
+ logger.warning("Audio final recortado es inválido. Usando audio final original.")
720
+ try:
721
+ trimmed_final_audio.close()
722
+ except:
723
+ pass
724
+ else:
725
+ if final_audio is not trimmed_final_audio:
726
+ try:
727
+ final_audio.close()
728
+ except:
729
+ pass
730
+ final_audio = trimmed_final_audio
731
+ logger.warning("Audio final recortado para que coincida con la duración del video.")
732
  except Exception as e:
733
+ logger.warning(f"Error ajustando duración del audio final: {str(e)}")
734
+ video_final = video_base.set_audio(final_audio)
735
+ output_filename = f"video_{int(time.time())}.mp4"
 
 
736
  output_path = os.path.join(temp_dir_intermediate, output_filename)
737
  permanent_path = f"/tmp/{output_filename}"
 
 
738
  video_final.write_videofile(
739
  output_path,
740
  fps=24,
 
748
  ],
749
  logger='bar'
750
  )
 
 
751
  try:
752
+ shutil.copy(output_path, permanent_path)
753
  logger.info(f"Video guardado permanentemente en: {permanent_path}")
754
  except Exception as move_error:
755
  logger.error(f"Error moviendo archivo: {str(move_error)}. Usando path original.")
756
  permanent_path = output_path
 
 
757
  try:
758
  video_final.close()
759
  if 'video_base' in locals() and video_base is not None and video_base is not video_final:
760
  video_base.close()
761
  except Exception as close_error:
762
  logger.error(f"Error cerrando clips: {str(close_error)}")
 
763
  total_time = (datetime.now() - start_time).total_seconds()
764
  logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {permanent_path} | Tiempo total: {total_time:.2f}s")
 
765
  return permanent_path
 
766
  except ValueError as ve:
767
+ logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
768
+ raise ve
769
  except Exception as e:
770
  logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
771
  raise e
772
  finally:
773
  logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
 
774
  for clip in source_clips:
775
  try:
776
  clip.close()
777
  except Exception as e:
778
  logger.warning(f"Error cerrando clip de video fuente en finally: {str(e)}")
 
779
  for clip_segment in clips_to_concatenate:
780
+ try:
781
+ clip_segment.close()
782
+ except Exception as e:
783
+ logger.warning(f"Error cerrando segmento de video en finally: {str(e)}")
 
784
  if musica_audio is not None:
785
  try:
786
  musica_audio.close()
787
  except Exception as e:
788
  logger.warning(f"Error cerrando musica_audio (procesada) en finally: {str(e)}")
 
789
  if musica_audio_original is not None and musica_audio_original is not musica_audio:
790
+ try:
791
+ musica_audio_original.close()
792
+ except Exception as e:
793
+ logger.warning(f"Error cerrando musica_audio_original en finally: {str(e)}")
 
794
  if audio_tts is not None and audio_tts is not audio_tts_original:
795
+ try:
796
+ audio_tts.close()
797
+ except Exception as e:
798
+ logger.warning(f"Error cerrando audio_tts (procesada) en finally: {str(e)}")
 
799
  if audio_tts_original is not None:
800
+ try:
801
+ audio_tts_original.close()
802
+ except Exception as e:
803
+ logger.warning(f"Error cerrando audio_tts_original en finalmente: {str(e)}")
 
804
  if video_final is not None:
805
  try:
806
  video_final.close()
807
  except Exception as e:
808
  logger.warning(f"Error cerrando video_final en finally: {str(e)}")
809
  elif video_base is not None and video_base is not video_final:
810
+ try:
811
+ video_base.close()
812
+ except Exception as e:
813
+ logger.warning(f"Error cerrando video_base en finally: {str(e)}")
 
814
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
815
+ final_output_in_temp = None
816
+ if output_filename:
817
+ final_output_in_temp = os.path.join(temp_dir_intermediate, output_filename)
818
+ for path in temp_intermediate_files:
819
+ try:
820
+ if os.path.isfile(path) and path != final_output_in_temp and path != permanent_path:
821
+ logger.debug(f"Eliminando archivo temporal intermedio: {path}")
822
+ os.remove(path)
823
+ elif os.path.isfile(path) and (path == final_output_in_temp or path == permanent_path):
824
+ logger.debug(f"Saltando eliminación del archivo de video final: {path}")
825
+ except Exception as e:
826
+ logger.warning(f"No se pudo eliminar archivo temporal intermedio {path}: {str(e)}")
827
+ logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
828
 
829
+ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
 
830
  logger.info("="*80)
831
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
 
 
832
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
 
833
  output_video = None
834
  output_file = None
835
  status_msg = gr.update(value="⏳ Procesando...", interactive=False)
 
836
  if not input_text or not input_text.strip():
837
  logger.warning("Texto de entrada vacío.")
 
838
  return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
 
 
 
839
  voice_ids_disponibles = [v[1] for v in AVAILABLE_VOICES]
840
  if selected_voice not in voice_ids_disponibles:
841
  logger.warning(f"Voz seleccionada inválida o no encontrada en la lista: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}.")
842
+ selected_voice = DEFAULT_VOICE_ID
843
  else:
844
  logger.info(f"Voz seleccionada validada: {selected_voice}")
 
 
845
  logger.info(f"Tipo de entrada: {prompt_type}")
846
  logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
847
  if musica_file:
848
  logger.info(f"Archivo de música recibido: {musica_file}")
849
  else:
850
  logger.info("No se proporcionó archivo de música.")
851
+ logger.info(f"Voz final a usar (ID): {selected_voice}")
 
852
  try:
853
  logger.info("Llamando a crear_video...")
854
+ video_path = crear_video(prompt_type, input_text, selected_voice, musica_file)
 
 
855
  if video_path and os.path.exists(video_path):
856
  logger.info(f"crear_video retornó path: {video_path}")
857
  logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
858
+ output_video = video_path
859
+ output_file = video_path
860
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
861
  else:
862
  logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
863
  status_msg = gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
 
864
  except ValueError as ve:
865
  logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
866
  status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
 
871
  logger.info("Fin del handler run_app.")
872
  return output_video, output_file, status_msg
873
 
 
 
874
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
875
  .gradio-container {max-width: 800px; margin: auto;}
876
  h1 {text-align: center;}
877
  """) as app:
 
878
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
879
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
 
880
  with gr.Row():
881
  with gr.Column():
882
  prompt_type = gr.Radio(
 
884
  label="Método de Entrada",
885
  value="Generar Guion con IA"
886
  )
 
 
887
  with gr.Column(visible=True) as ia_guion_column:
888
  prompt_ia = gr.Textbox(
889
  label="Tema para IA",
 
892
  max_lines=4,
893
  value=""
894
  )
 
895
  with gr.Column(visible=False) as manual_guion_column:
896
  prompt_manual = gr.Textbox(
897
  label="Tu Guion Completo",
 
900
  max_lines=10,
901
  value=""
902
  )
 
903
  musica_input = gr.Audio(
904
  label="Música de fondo (opcional)",
905
  type="filepath",
906
  interactive=True,
907
  value=None
908
  )
 
 
909
  voice_dropdown = gr.Dropdown(
910
  label="Seleccionar Voz para Guion",
911
  choices=AVAILABLE_VOICES,
912
  value=DEFAULT_VOICE_ID,
913
  interactive=True
914
  )
 
 
915
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
 
916
  with gr.Column():
917
  video_output = gr.Video(
918
  label="Previsualización del Video Generado",
 
931
  placeholder="Esperando acción...",
932
  value="Esperando entrada..."
933
  )
 
 
934
  prompt_type.change(
935
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
936
  gr.update(visible=x == "Usar Mi Guion")),
937
  inputs=prompt_type,
938
  outputs=[ia_guion_column, manual_guion_column]
939
  )
 
 
940
  generate_btn.click(
941
  lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
942
  outputs=[video_output, file_output, status_output],
 
950
  inputs=[video_output, file_output, status_output],
951
  outputs=[file_output]
952
  )
 
953
  gr.Markdown("### Instrucciones:")
954
  gr.Markdown("""
955
  1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
 
975
  except Exception as e:
976
  logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
977
  except Exception as e:
978
+ logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
979
+ os.environ['GRADIO_SERVER_TIMEOUT'] = '1200'
 
 
 
980
  logger.info("Iniciando aplicación Gradio...")
981
  try:
982
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)