Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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]}...
|
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 |
-
|
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 |
-
|
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(
|
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 |
-
|
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 |
-
|
348 |
-
words = clean_text.lower().split()
|
349 |
-
|
350 |
-
|
351 |
-
|
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) # ***
|
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 |
-
|
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 |
-
|
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 |
-
|
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 |
-
#
|
444 |
-
|
445 |
videos_data = []
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
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 |
-
|
477 |
-
|
478 |
-
|
479 |
-
if
|
480 |
-
path
|
481 |
-
|
482 |
-
|
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
|
500 |
clip = None
|
501 |
try:
|
502 |
clip = VideoFileClip(path)
|
503 |
source_clips.append(clip)
|
504 |
-
if clip.
|
505 |
|
506 |
-
|
507 |
-
if
|
508 |
-
|
509 |
-
|
510 |
-
|
511 |
-
|
512 |
-
|
513 |
-
|
514 |
-
|
515 |
-
|
516 |
-
|
517 |
-
|
518 |
-
|
519 |
-
|
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 |
-
|
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 |
-
|
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
|
564 |
-
final_audio = CompositeAudioClip([musica_audio.volumex(0.2),
|
565 |
except Exception as e:
|
566 |
-
logger.warning(f"Error procesando música: {str(e)}"
|
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 |
-
|
595 |
-
|
596 |
-
for
|
597 |
-
if
|
598 |
-
try:
|
599 |
-
except Exception:
|
|
|
|
|
600 |
if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
|
601 |
-
|
602 |
-
|
603 |
-
|
604 |
-
|
605 |
-
|
606 |
-
|
607 |
-
|
608 |
-
|
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 |
-
|
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 |
-
# ***
|
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,
|
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 |
-
|
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 |
-
|
697 |
-
|
698 |
-
|
699 |
-
|
700 |
-
|
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...")
|