gnosticdev commited on
Commit
bfc799a
·
verified ·
1 Parent(s): 06124ef

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +105 -304
app.py CHANGED
@@ -16,9 +16,16 @@ import json
16
  from collections import Counter
17
  import threading
18
  import time
 
 
 
 
 
 
 
19
 
20
  # Variable global para TTS
21
- tts_model = None
22
 
23
  # Configuración de logging
24
  logging.basicConfig(
@@ -63,6 +70,7 @@ except Exception as e:
63
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
64
  kw_model = None
65
 
 
66
  def schedule_deletion(directory_path, delay_seconds):
67
  """Programa la eliminación de un directorio después de un cierto tiempo."""
68
  logger.info(f"PROGRAMADA eliminación del directorio '{directory_path}' en {delay_seconds / 3600:.1f} horas.")
@@ -198,7 +206,6 @@ def text_to_speech(text, output_path, voice=None):
198
 
199
  try:
200
  tts = TTS(model_name="tts_models/es/css10/vits", progress_bar=False, gpu=False)
201
-
202
  text = text.replace("na hora", "A la hora")
203
  text = re.sub(r'[^\w\s.,!?áéíóúñÁÉÍÓÚÑ]', '', text)
204
  if len(text) > 500:
@@ -239,7 +246,7 @@ def download_video_file(url, temp_dir):
239
  logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
240
  return output_path
241
  else:
242
- 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")
243
  if os.path.exists(output_path):
244
  os.remove(output_path)
245
  return None
@@ -256,59 +263,21 @@ def loop_audio_to_length(audio_clip, target_duration):
256
 
257
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
258
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
259
- try:
260
- sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
261
- return AudioClip(lambda t: 0, duration=target_duration, sr=sr)
262
- except Exception as e:
263
- logger.error(f"Could not create silence clip: {e}", exc_info=True)
264
- return AudioFileClip(filename="")
265
 
266
  if audio_clip.duration >= target_duration:
267
  logger.debug("Audio clip already longer or equal to target. Trimming.")
268
- trimmed_clip = audio_clip.subclip(0, target_duration)
269
- if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
270
- logger.error("Trimmed audio clip is invalid.")
271
- try: trimmed_clip.close()
272
- except: pass
273
- return AudioFileClip(filename="")
274
- return trimmed_clip
275
 
276
  loops = math.ceil(target_duration / audio_clip.duration)
277
  logger.debug(f"Creando {loops} loops de audio")
278
 
279
- audio_segments = [audio_clip] * loops
280
- looped_audio = None
281
- final_looped_audio = None
282
  try:
283
- looped_audio = concatenate_audioclips(audio_segments)
284
-
285
- if looped_audio.duration is None or looped_audio.duration <= 0:
286
- logger.error("Concatenated audio clip is invalid (None or zero duration).")
287
- raise ValueError("Invalid concatenated audio.")
288
-
289
- final_looped_audio = looped_audio.subclip(0, target_duration)
290
-
291
- if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
292
- logger.error("Final subclipped audio clip is invalid (None or zero duration).")
293
- raise ValueError("Invalid final subclipped audio.")
294
-
295
- return final_looped_audio
296
-
297
  except Exception as e:
298
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
299
- try:
300
- if audio_clip.duration is not None and audio_clip.duration > 0:
301
- logger.warning("Returning original audio clip (may be too short).")
302
- return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
303
- except:
304
- pass
305
- logger.error("Fallback to original audio clip failed.")
306
- return AudioFileClip(filename="")
307
-
308
- finally:
309
- if looped_audio is not None and looped_audio is not final_looped_audio:
310
- try: looped_audio.close()
311
- except: pass
312
 
313
  def extract_visual_keywords_from_script(script_text):
314
  logger.info("Extrayendo palabras clave del guion")
@@ -321,48 +290,27 @@ def extract_visual_keywords_from_script(script_text):
321
 
322
  if kw_model:
323
  try:
324
- logger.debug("Intentando extracción con KeyBERT...")
325
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
326
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
327
-
328
- all_keywords = keywords1 + keywords2
329
- all_keywords.sort(key=lambda item: item[1], reverse=True)
330
-
331
  seen_keywords = set()
332
  for keyword, score in all_keywords:
333
  formatted_keyword = keyword.lower().replace(" ", "+")
334
  if formatted_keyword and formatted_keyword not in seen_keywords:
335
  keywords_list.append(formatted_keyword)
336
  seen_keywords.add(formatted_keyword)
337
- if len(keywords_list) >= 5:
338
- break
339
-
340
  if keywords_list:
341
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
342
  return keywords_list
343
-
344
  except Exception as e:
345
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
346
 
347
- logger.debug("Extrayendo palabras clave con método simple...")
348
- words = clean_text.lower().split()
349
- 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",
350
- "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
351
- "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á"}
352
-
353
- valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
354
-
355
- if not valid_words:
356
- logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
357
- return ["naturaleza", "ciudad", "paisaje"]
358
-
359
- word_counts = Counter(valid_words)
360
- top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
361
-
362
- if not top_keywords:
363
- logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
364
- return ["naturaleza", "ciudad", "paisaje"]
365
-
366
  logger.info(f"Palabras clave finales: {top_keywords}")
367
  return top_keywords
368
 
@@ -373,7 +321,7 @@ def crear_video(prompt_type, input_text, musica_file=None):
373
 
374
  start_time = datetime.now()
375
  temp_dir_intermediate = None
376
- TARGET_RESOLUTION = (1280, 720) # *** MODIFICACIÓN: Resolución 720p ***
377
 
378
  audio_tts_original = None
379
  musica_audio_original = None
@@ -386,172 +334,85 @@ def crear_video(prompt_type, input_text, musica_file=None):
386
 
387
  try:
388
  # 1. Generar o usar guion
389
- if prompt_type == "Generar Guion con IA":
390
- guion = generate_script(input_text)
391
- else:
392
- guion = input_text.strip()
393
-
394
- logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
395
-
396
- if not guion.strip():
397
- logger.error("El guion resultante está vacío o solo contiene espacios.")
398
- raise ValueError("El guion está vacío.")
399
  guion = guion.replace("na hora", "A la hora")
400
 
401
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
402
- logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
403
  temp_intermediate_files = []
404
 
405
  # 2. Generar audio de voz
406
- logger.info("Generando audio de voz...")
407
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
408
- tts_success = text_to_speech(guion, voz_path)
409
-
410
- if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 1000:
411
- logger.error(f"Fallo en la generación de voz. Archivo de audio no creado o es muy pequeño: {voz_path}")
412
  raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
413
  temp_intermediate_files.append(voz_path)
414
-
415
  audio_tts_original = AudioFileClip(voz_path)
416
-
417
- if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
418
- logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0).")
419
- try: audio_tts_original.close()
420
- except: pass
421
- audio_tts_original = None
422
- raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
423
-
424
  audio_tts = audio_tts_original
425
  audio_duration = audio_tts_original.duration
426
- logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
427
-
428
- if audio_duration < 1.0:
429
- logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
430
- raise ValueError("Generated voice audio is too short (min 1 second required).")
431
-
432
- # 3. Extraer palabras clave
433
- logger.info("Extrayendo palabras clave...")
434
- try:
435
- keywords = extract_visual_keywords_from_script(guion)
436
- logger.info(f"Palabras clave identificadas: {keywords}")
437
- except Exception as e:
438
- logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
439
- keywords = ["naturaleza", "paisaje"]
440
- if not keywords:
441
- keywords = ["video", "background"]
442
 
443
- # 4. Buscar y descargar videos
444
- logger.info("Buscando videos en Pexels...")
445
  videos_data = []
446
- total_desired_videos = 10
447
- per_page_per_keyword = max(1, total_desired_videos // len(keywords))
448
-
449
- for keyword in keywords:
450
- if len(videos_data) >= total_desired_videos: break
451
- try:
452
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
453
- if videos:
454
- videos_data.extend(videos)
455
- except Exception as e:
456
- logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
457
-
458
- if len(videos_data) < total_desired_videos / 2:
459
- logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
460
- for keyword in ["nature", "city", "background", "abstract"]:
461
- if len(videos_data) >= total_desired_videos: break
462
- try:
463
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
464
- if videos:
465
- videos_data.extend(videos)
466
- except Exception as e:
467
- logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
468
-
469
- if not videos_data:
470
- logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
471
- raise ValueError("No se encontraron videos adecuados en Pexels.")
472
 
 
473
  video_paths = []
474
- logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
475
  for video in videos_data:
476
- if 'video_files' not in video or not video['video_files']: continue
477
- try:
478
- best_quality = next((vf for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0), reverse=True) if 'link' in vf), None)
479
- if best_quality:
480
- path = download_video_file(best_quality['link'], temp_dir_intermediate)
481
- if path:
482
- video_paths.append(path)
483
- temp_intermediate_files.append(path)
484
- except Exception as e:
485
- logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
486
-
487
- logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
488
- if not video_paths:
489
- logger.error("No se pudo descargar ningún archivo de video utilizable.")
490
- raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
491
 
492
  # 5. Procesar y concatenar clips de video
493
- logger.info("Procesando y concatenando videos descargados...")
494
  current_duration = 0
495
- min_clip_duration = 0.5
496
- max_clip_segment = 10.0
497
-
498
  for i, path in enumerate(video_paths):
499
- if current_duration >= audio_duration + max_clip_segment: break
500
  clip = None
501
  try:
502
  clip = VideoFileClip(path)
503
  source_clips.append(clip)
504
- if clip.reader is None or clip.duration is None or clip.duration <= 0: continue
505
 
506
- remaining_needed = audio_duration - current_duration
507
- if remaining_needed > 0:
508
- segment_duration = min(min(clip.duration, max_clip_segment), remaining_needed + min_clip_duration)
509
- segment_duration = max(min_clip_duration, segment_duration)
510
- if segment_duration >= min_clip_duration:
511
- try:
512
- sub_raw = clip.subclip(0, segment_duration)
513
- # *** MODIFICACIÓN: Redimensionar y recortar cada subclip a 720p ***
514
- sub = sub_raw.resize(height=TARGET_RESOLUTION[1]).crop(x_center='center', width=TARGET_RESOLUTION[0], height=TARGET_RESOLUTION[1])
515
- sub_raw.close() # Liberar memoria del clip intermedio
516
-
517
- if sub.reader is None or sub.duration is None or sub.duration <= 0:
518
- try: sub.close()
519
- except: pass
520
- continue
521
-
522
- clips_to_concatenate.append(sub)
523
- current_duration += sub.duration
524
- logger.debug(f"Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
525
- except Exception as sub_e:
526
- logger.warning(f"Error creando subclip de {path}: {str(sub_e)}")
527
- except Exception as e:
528
- logger.warning(f"Error procesando video {path}: {str(e)}", exc_info=True)
529
 
 
 
 
530
  if not clips_to_concatenate:
531
- logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
532
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
533
 
534
- logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
535
  video_base = concatenate_videoclips(clips_to_concatenate, method="chain")
536
- for seg in clips_to_concatenate:
537
- try: seg.close()
538
- except: pass
539
  clips_to_concatenate = []
540
 
541
  if video_base.duration < audio_duration:
542
- logger.info(f"Video base ({video_base.duration:.2f}s) más corto que audio ({audio_duration:.2f}s). Repitiendo...")
543
  video_base = video_base.loop(duration=audio_duration)
544
-
545
  if video_base.duration > audio_duration:
546
- logger.info(f"Recortando video base ({video_base.duration:.2f}s) para coincidir con audio ({audio_duration:.2f}s).")
547
- video_base = video_base.subclip(0, audio_duration)
548
 
549
- if video_base is None or video_base.duration is None or video_base.duration <= 0:
550
- raise ValueError("Video base final es inválido.")
551
-
552
  # 6. Manejar música de fondo
553
- logger.info("Procesando audio...")
554
- final_audio = audio_tts_original
555
  if musica_file:
556
  try:
557
  music_path = os.path.join(temp_dir_intermediate, "musica_bg.mp3")
@@ -560,23 +421,14 @@ def crear_video(prompt_type, input_text, musica_file=None):
560
  musica_audio_original = AudioFileClip(music_path)
561
  if musica_audio_original.duration > 0:
562
  musica_audio = loop_audio_to_length(musica_audio_original, video_base.duration)
563
- if musica_audio and musica_audio.duration > 0:
564
- final_audio = CompositeAudioClip([musica_audio.volumex(0.2), audio_tts_original.volumex(1.0)])
565
  except Exception as e:
566
- logger.warning(f"Error procesando música: {str(e)}", exc_info=True)
567
-
568
- if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
569
- logger.warning(f"Ajustando duración de audio final ({final_audio.duration:.2f}s) a la del video ({video_base.duration:.2f}s).")
570
- final_audio = final_audio.subclip(0, video_base.duration)
571
 
572
  # 7. Crear video final
573
- logger.info("Renderizando video final...")
574
  video_final = video_base.set_audio(final_audio)
575
-
576
- output_filename = "final_video.mp4"
577
- output_path = os.path.join(temp_dir_intermediate, output_filename)
578
- logger.info(f"Escribiendo video final a: {output_path}")
579
-
580
  video_final.write_videofile(
581
  filename=output_path, fps=24, threads=4, codec="libx264",
582
  audio_codec="aac", preset="medium", logger='bar'
@@ -584,69 +436,58 @@ def crear_video(prompt_type, input_text, musica_file=None):
584
 
585
  total_time = (datetime.now() - start_time).total_seconds()
586
  logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
587
-
588
  return output_path
589
 
590
  except Exception as e:
591
  logger.critical(f"ERROR CRÍTICO en crear_video: {str(e)}", exc_info=True)
592
  raise
593
  finally:
594
- logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
595
- clips_to_close = [video_base, video_final, audio_tts_original, musica_audio_original, musica_audio] + source_clips + clips_to_concatenate
596
- for clip in clips_to_close:
597
- if clip:
598
- try: clip.close()
599
- except Exception: pass
 
 
600
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
601
- final_output_in_temp = os.path.join(temp_dir_intermediate, "final_video.mp4")
602
- for path in temp_intermediate_files:
603
- if os.path.isfile(path) and path != final_output_in_temp:
604
- try:
605
- os.remove(path)
606
- logger.debug(f"Eliminando archivo temporal intermedio: {path}")
607
- except Exception: pass
608
- logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
609
 
610
  def run_app(prompt_type, prompt_ia, prompt_manual, musica_file):
611
  logger.info("="*80)
612
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
613
 
614
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
615
-
616
- output_video = None
617
- output_file = gr.update(value=None, visible=False)
618
- status_msg = gr.update(value="⏳ Procesando...", interactive=False)
619
 
620
  if not input_text or not input_text.strip():
621
- logger.warning("Texto de entrada vacío.")
622
- status_msg = gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
623
- return output_video, output_file, status_msg
624
 
625
  try:
626
- logger.info("Llamando a crear_video...")
627
  video_path = crear_video(prompt_type, input_text, musica_file)
628
-
629
  if video_path and os.path.exists(video_path):
630
- logger.info(f"crear_video retornó path: {video_path}")
631
  output_video = video_path
632
  output_file = gr.update(value=video_path, visible=True)
633
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
634
 
635
- # *** MODIFICACIÓN: Programar la eliminación automática del directorio del video ***
636
  temp_dir_to_delete = os.path.dirname(video_path)
637
- delay_hours = 3
638
  deletion_thread = threading.Thread(
639
  target=schedule_deletion,
640
- args=(temp_dir_to_delete, delay_hours * 3600)
641
  )
642
- deletion_thread.daemon = True
643
  deletion_thread.start()
644
  else:
645
- logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
646
  status_msg = gr.update(value="❌ Error: La generación del video falló.", interactive=False)
647
-
648
  except Exception as e:
649
- logger.critical(f"Error crítico durante la creación del video: {str(e)}", exc_info=True)
650
  status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
651
  finally:
652
  logger.info("Fin del handler run_app.")
@@ -662,76 +503,36 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
662
 
663
  with gr.Row():
664
  with gr.Column():
665
- prompt_type = gr.Radio(
666
- ["Generar Guion con IA", "Usar Mi Guion"],
667
- label="Método de Entrada",
668
- value="Generar Guion con IA"
669
- )
670
-
671
  with gr.Column(visible=True) as ia_guion_column:
672
- prompt_ia = gr.Textbox(
673
- label="Tema para IA",
674
- lines=2,
675
- placeholder="Ej: Un paisaje natural con montañas y ríos...",
676
- max_lines=4
677
- )
678
-
679
  with gr.Column(visible=False) as manual_guion_column:
680
- prompt_manual = gr.Textbox(
681
- label="Tu Guion Completo",
682
- lines=5,
683
- placeholder="Ej: En este video exploraremos los misterios del océano...",
684
- max_lines=10
685
- )
686
-
687
- musica_input = gr.Audio(
688
- label="Música de fondo (opcional)",
689
- type="filepath"
690
- )
691
-
692
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
693
 
694
  with gr.Column():
695
- video_output = gr.Video(
696
- label="Previsualización del Video Generado",
697
- interactive=False,
698
- height=400
699
- )
700
- file_output = gr.File(
701
- label="Descargar Archivo de Video",
702
- interactive=False,
703
- visible=False
704
- )
705
- status_output = gr.Textbox(
706
- label="Estado",
707
- interactive=False,
708
- show_label=False,
709
- placeholder="Esperando acción..."
710
- )
711
-
712
- prompt_type.change(
713
- lambda x: (gr.update(visible=x == "Generar Guion con IA"),
714
- gr.update(visible=x == "Usar Mi Guion")),
715
- inputs=prompt_type,
716
- outputs=[ia_guion_column, manual_guion_column]
717
- )
718
-
719
  generate_btn.click(
720
- lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.")),
721
- outputs=[video_output, file_output, status_output],
722
- queue=True,
723
  ).then(
724
- run_app,
725
- inputs=[prompt_type, prompt_ia, prompt_manual, musica_input],
726
- outputs=[video_output, file_output, status_output]
727
  )
 
728
  gr.Markdown("### Instrucciones:")
729
  gr.Markdown("1. **Clave API de Pexels:** Asegúrate de tener la variable de entorno `PEXELS_API_KEY`.\n"
730
  "2. **Selecciona el método** y escribe tu tema o guion.\n"
731
  "3. **Sube música** (opcional).\n"
732
  "4. Haz clic en **Generar Video** y espera.\n"
733
  "5. El video generado se eliminará automáticamente del servidor después de 3 horas.")
734
-
 
735
 
736
  if __name__ == "__main__":
737
  logger.info("Iniciando aplicación Gradio...")
 
16
  from collections import Counter
17
  import threading
18
  import time
19
+ from PIL import Image
20
+
21
+ # *** CAMBIO 1 (CORRECCIÓN): Parche para la compatibilidad de Pillow >= 10.0 ***
22
+ # Las versiones nuevas de Pillow eliminaron 'ANTIALIAS'. MoviePy aún lo usa.
23
+ # Este código restaura la compatibilidad haciendo que ANTIALIAS apunte a LANCZOS.
24
+ if not hasattr(Image, 'ANTIALIAS'):
25
+ Image.ANTIALIAS = Image.LANCZOS
26
 
27
  # Variable global para TTS
28
+ tts_model = None
29
 
30
  # Configuración de logging
31
  logging.basicConfig(
 
70
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
71
  kw_model = None
72
 
73
+ # *** CAMBIO 3 (AÑADIDO): Función para eliminar directorios temporalmente ***
74
  def schedule_deletion(directory_path, delay_seconds):
75
  """Programa la eliminación de un directorio después de un cierto tiempo."""
76
  logger.info(f"PROGRAMADA eliminación del directorio '{directory_path}' en {delay_seconds / 3600:.1f} horas.")
 
206
 
207
  try:
208
  tts = TTS(model_name="tts_models/es/css10/vits", progress_bar=False, gpu=False)
 
209
  text = text.replace("na hora", "A la hora")
210
  text = re.sub(r'[^\w\s.,!?áéíóúñÁÉÍÓÚÑ]', '', text)
211
  if len(text) > 500:
 
246
  logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
247
  return output_path
248
  else:
249
+ logger.warning(f"Descarga parece incompleta o vacía para {url[:80]}...")
250
  if os.path.exists(output_path):
251
  os.remove(output_path)
252
  return None
 
263
 
264
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
265
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
266
+ return None
 
 
 
 
 
267
 
268
  if audio_clip.duration >= target_duration:
269
  logger.debug("Audio clip already longer or equal to target. Trimming.")
270
+ return audio_clip.subclip(0, target_duration)
 
 
 
 
 
 
271
 
272
  loops = math.ceil(target_duration / audio_clip.duration)
273
  logger.debug(f"Creando {loops} loops de audio")
274
 
 
 
 
275
  try:
276
+ looped_audio = concatenate_audioclips([audio_clip] * loops)
277
+ return looped_audio.subclip(0, target_duration)
 
 
 
 
 
 
 
 
 
 
 
 
278
  except Exception as e:
279
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
280
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
  def extract_visual_keywords_from_script(script_text):
283
  logger.info("Extrayendo palabras clave del guion")
 
290
 
291
  if kw_model:
292
  try:
 
293
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
294
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
295
+ all_keywords = sorted(keywords1 + keywords2, key=lambda item: item[1], reverse=True)
 
 
 
296
  seen_keywords = set()
297
  for keyword, score in all_keywords:
298
  formatted_keyword = keyword.lower().replace(" ", "+")
299
  if formatted_keyword and formatted_keyword not in seen_keywords:
300
  keywords_list.append(formatted_keyword)
301
  seen_keywords.add(formatted_keyword)
302
+ if len(keywords_list) >= 5: break
 
 
303
  if keywords_list:
304
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
305
  return keywords_list
 
306
  except Exception as e:
307
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
308
 
309
+ stop_words = set(["el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con", "para", "del", "al", "por", "su", "sus"])
310
+ words = [word for word in clean_text.lower().split() if len(word) > 3 and word not in stop_words]
311
+ if not words: return ["naturaleza", "ciudad", "paisaje"]
312
+
313
+ top_keywords = [word.replace(" ", "+") for word, _ in Counter(words).most_common(5)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  logger.info(f"Palabras clave finales: {top_keywords}")
315
  return top_keywords
316
 
 
321
 
322
  start_time = datetime.now()
323
  temp_dir_intermediate = None
324
+ TARGET_RESOLUTION = (1280, 720) # *** CAMBIO 2 (AÑADIDO): Resolución 720p ***
325
 
326
  audio_tts_original = None
327
  musica_audio_original = None
 
334
 
335
  try:
336
  # 1. Generar o usar guion
337
+ guion = generate_script(input_text) if prompt_type == "Generar Guion con IA" else input_text.strip()
338
+ if not guion.strip(): raise ValueError("El guion está vacío.")
 
 
 
 
 
 
 
 
339
  guion = guion.replace("na hora", "A la hora")
340
 
341
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
 
342
  temp_intermediate_files = []
343
 
344
  # 2. Generar audio de voz
 
345
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
346
+ if not text_to_speech(guion, voz_path) or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 1000:
 
 
 
347
  raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
348
  temp_intermediate_files.append(voz_path)
 
349
  audio_tts_original = AudioFileClip(voz_path)
350
+ if audio_tts_original.duration is None or audio_tts_original.duration < 1.0:
351
+ raise ValueError("Generated voice audio is too short (min 1 second required).")
 
 
 
 
 
 
352
  audio_tts = audio_tts_original
353
  audio_duration = audio_tts_original.duration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
+ # 3. Extraer palabras clave y buscar videos
356
+ keywords = extract_visual_keywords_from_script(guion)
357
  videos_data = []
358
+ for keyword in keywords + ["nature", "city", "background", "abstract"]:
359
+ if len(videos_data) >= 10: break
360
+ videos_data.extend(buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=3))
361
+ if not videos_data: raise ValueError("No se encontraron videos adecuados en Pexels.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
+ # 4. Descargar videos
364
  video_paths = []
 
365
  for video in videos_data:
366
+ best_quality = next((vf for vf in sorted(video.get('video_files', []), key=lambda x: x.get('width', 0), reverse=True) if 'link' in vf), None)
367
+ if best_quality:
368
+ path = download_video_file(best_quality['link'], temp_dir_intermediate)
369
+ if path:
370
+ video_paths.append(path)
371
+ temp_intermediate_files.append(path)
372
+ if not video_paths: raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
 
 
 
 
 
 
 
 
373
 
374
  # 5. Procesar y concatenar clips de video
 
375
  current_duration = 0
 
 
 
376
  for i, path in enumerate(video_paths):
377
+ if current_duration >= audio_duration: break
378
  clip = None
379
  try:
380
  clip = VideoFileClip(path)
381
  source_clips.append(clip)
382
+ if clip.duration is None or clip.duration <= 0.5: continue
383
 
384
+ segment_duration = min(clip.duration, audio_duration - current_duration, 10.0)
385
+ if segment_duration >= 0.5:
386
+ sub_raw = clip.subclip(0, segment_duration)
387
+
388
+ # *** CAMBIO 2 (AÑADIDO): Redimensionar y recortar CADA clip a 720p ***
389
+ sub_resized = sub_raw.resize(height=TARGET_RESOLUTION[1]).crop(x_center='center', y_center='center', width=TARGET_RESOLUTION[0], height=TARGET_RESOLUTION[1])
390
+ sub_raw.close() # Liberar memoria del clip intermedio sin redimensionar
391
+
392
+ if sub_resized.duration is not None and sub_resized.duration > 0:
393
+ clips_to_concatenate.append(sub_resized)
394
+ current_duration += sub_resized.duration
395
+ logger.debug(f"Segmento añadido: {sub_resized.duration:.1f}s (total: {current_duration:.1f}/{audio_duration:.1f}s)")
396
+ else:
397
+ sub_resized.close()
 
 
 
 
 
 
 
 
 
398
 
399
+ except Exception as e:
400
+ logger.warning(f"Error procesando video {path}: {str(e)}")
401
+
402
  if not clips_to_concatenate:
 
403
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
404
 
 
405
  video_base = concatenate_videoclips(clips_to_concatenate, method="chain")
406
+ for seg in clips_to_concatenate: seg.close() # Limpieza de los clips en la lista
 
 
407
  clips_to_concatenate = []
408
 
409
  if video_base.duration < audio_duration:
 
410
  video_base = video_base.loop(duration=audio_duration)
 
411
  if video_base.duration > audio_duration:
412
+ video_base = video_base.subclip(0, audio_duration)
 
413
 
 
 
 
414
  # 6. Manejar música de fondo
415
+ final_audio = audio_tts
 
416
  if musica_file:
417
  try:
418
  music_path = os.path.join(temp_dir_intermediate, "musica_bg.mp3")
 
421
  musica_audio_original = AudioFileClip(music_path)
422
  if musica_audio_original.duration > 0:
423
  musica_audio = loop_audio_to_length(musica_audio_original, video_base.duration)
424
+ if musica_audio:
425
+ final_audio = CompositeAudioClip([musica_audio.volumex(0.2), audio_tts.volumex(1.0)])
426
  except Exception as e:
427
+ logger.warning(f"Error procesando música: {str(e)}")
 
 
 
 
428
 
429
  # 7. Crear video final
 
430
  video_final = video_base.set_audio(final_audio)
431
+ output_path = os.path.join(temp_dir_intermediate, "final_video.mp4")
 
 
 
 
432
  video_final.write_videofile(
433
  filename=output_path, fps=24, threads=4, codec="libx264",
434
  audio_codec="aac", preset="medium", logger='bar'
 
436
 
437
  total_time = (datetime.now() - start_time).total_seconds()
438
  logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
 
439
  return output_path
440
 
441
  except Exception as e:
442
  logger.critical(f"ERROR CRÍTICO en crear_video: {str(e)}", exc_info=True)
443
  raise
444
  finally:
445
+ # Limpieza de todos los recursos de MoviePy
446
+ all_clips = [audio_tts_original, musica_audio_original, audio_tts, musica_audio, video_base, video_final] + source_clips + clips_to_concatenate
447
+ for clip_resource in all_clips:
448
+ if clip_resource:
449
+ try: clip_resource.close()
450
+ except Exception as close_e: logger.warning(f"Error menor cerrando un clip: {close_e}")
451
+
452
+ # Limpieza de archivos temporales
453
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
454
+ final_output_in_temp = os.path.join(temp_dir_intermediate, "final_video.mp4")
455
+ for path in temp_intermediate_files:
456
+ if os.path.isfile(path) and path != final_output_in_temp:
457
+ try:
458
+ os.remove(path)
459
+ logger.debug(f"Eliminando archivo temporal intermedio: {path}")
460
+ except Exception as rm_e: logger.warning(f"No se pudo eliminar archivo temporal {path}: {rm_e}")
461
+ logger.info(f"Directorio temporal {temp_dir_intermediate} persistirá para Gradio.")
462
 
463
  def run_app(prompt_type, prompt_ia, prompt_manual, musica_file):
464
  logger.info("="*80)
465
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
466
 
467
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
468
+ output_video, output_file, status_msg = None, gr.update(value=None, visible=False), gr.update(value="⏳ Procesando...", interactive=False)
 
 
 
469
 
470
  if not input_text or not input_text.strip():
471
+ return output_video, output_file, gr.update(value="⚠️ Por favor, ingresa un guion o tema.", interactive=False)
 
 
472
 
473
  try:
 
474
  video_path = crear_video(prompt_type, input_text, musica_file)
 
475
  if video_path and os.path.exists(video_path):
 
476
  output_video = video_path
477
  output_file = gr.update(value=video_path, visible=True)
478
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
479
 
480
+ # *** CAMBIO 3 (AÑADIDO): Programar la eliminación automática del directorio del video ***
481
  temp_dir_to_delete = os.path.dirname(video_path)
 
482
  deletion_thread = threading.Thread(
483
  target=schedule_deletion,
484
+ args=(temp_dir_to_delete, 3 * 3600) # 3 horas en segundos
485
  )
486
+ deletion_thread.daemon = True # Permite que el programa principal termine aunque el hilo esté esperando
487
  deletion_thread.start()
488
  else:
 
489
  status_msg = gr.update(value="❌ Error: La generación del video falló.", interactive=False)
 
490
  except Exception as e:
 
491
  status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
492
  finally:
493
  logger.info("Fin del handler run_app.")
 
503
 
504
  with gr.Row():
505
  with gr.Column():
506
+ prompt_type = gr.Radio(["Generar Guion con IA", "Usar Mi Guion"], label="Método de Entrada", value="Generar Guion con IA")
 
 
 
 
 
507
  with gr.Column(visible=True) as ia_guion_column:
508
+ prompt_ia = gr.Textbox(label="Tema para IA", lines=2, placeholder="Ej: Un paisaje natural con montañas y ríos...")
 
 
 
 
 
 
509
  with gr.Column(visible=False) as manual_guion_column:
510
+ prompt_manual = gr.Textbox(label="Tu Guion Completo", lines=5, placeholder="Ej: En este video exploraremos los misterios del océano...")
511
+ musica_input = gr.Audio(label="Música de fondo (opcional)", type="filepath")
 
 
 
 
 
 
 
 
 
 
512
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
513
 
514
  with gr.Column():
515
+ video_output = gr.Video(label="Previsualización del Video Generado", interactive=False, height=400)
516
+ file_output = gr.File(label="Descargar Archivo de Video", interactive=False, visible=False)
517
+ status_output = gr.Textbox(label="Estado", interactive=False, show_label=False, placeholder="Esperando acción...")
518
+
519
+ prompt_type.change(lambda x: (gr.update(visible=x == "Generar Guion con IA"), gr.update(visible=x == "Usar Mi Guion")), inputs=prompt_type, outputs=[ia_guion_column, manual_guion_column])
520
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  generate_btn.click(
522
+ lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
523
+ outputs=[video_output, file_output, status_output], queue=True
 
524
  ).then(
525
+ run_app, inputs=[prompt_type, prompt_ia, prompt_manual, musica_input], outputs=[video_output, file_output, status_output]
 
 
526
  )
527
+
528
  gr.Markdown("### Instrucciones:")
529
  gr.Markdown("1. **Clave API de Pexels:** Asegúrate de tener la variable de entorno `PEXELS_API_KEY`.\n"
530
  "2. **Selecciona el método** y escribe tu tema o guion.\n"
531
  "3. **Sube música** (opcional).\n"
532
  "4. Haz clic en **Generar Video** y espera.\n"
533
  "5. El video generado se eliminará automáticamente del servidor después de 3 horas.")
534
+ gr.Markdown("---")
535
+ gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
536
 
537
  if __name__ == "__main__":
538
  logger.info("Iniciando aplicación Gradio...")