Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
@@ -15,6 +15,7 @@ import math
|
|
15 |
import shutil
|
16 |
import json
|
17 |
from collections import Counter
|
|
|
18 |
|
19 |
# Configuración de logging
|
20 |
logging.basicConfig(
|
@@ -30,32 +31,28 @@ logger.info("="*80)
|
|
30 |
logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
|
31 |
logger.info("="*80)
|
32 |
|
33 |
-
#
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
# Establecer una voz por defecto
|
57 |
-
DEFAULT_VOICE_ID = "es-ES-JuanNeural" if "es-ES-JuanNeural" in AVAILABLE_VOICES.values() else list(AVAILABLE_VOICES.values())[0]
|
58 |
-
DEFAULT_VOICE_NAME = next((k for k, v in AVAILABLE_VOICES.items() if v == DEFAULT_VOICE_ID), list(AVAILABLE_VOICES.keys())[0])
|
59 |
logger.info(f"Voz por defecto seleccionada: {DEFAULT_VOICE_NAME} ({DEFAULT_VOICE_ID})")
|
60 |
|
61 |
# Clave API de Pexels
|
@@ -63,20 +60,27 @@ PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
|
|
63 |
if not PEXELS_API_KEY:
|
64 |
logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
|
65 |
|
66 |
-
# Inicialización de modelos
|
67 |
MODEL_NAME = "datificate/gpt2-small-spanish"
|
68 |
logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
|
69 |
tokenizer = None
|
70 |
model = None
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
|
81 |
logger.info("Cargando modelo KeyBERT...")
|
82 |
kw_model = None
|
@@ -94,35 +98,32 @@ def buscar_videos_pexels(query, api_key, per_page=5):
|
|
94 |
|
95 |
logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
|
96 |
headers = {"Authorization": api_key}
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
|
124 |
-
|
125 |
-
return []
|
126 |
|
127 |
def generate_script(prompt, max_length=150):
|
128 |
logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
|
@@ -230,31 +231,36 @@ def download_video_file(url, temp_dir):
|
|
230 |
file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
|
231 |
output_path = os.path.join(temp_dir, file_name)
|
232 |
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
247 |
|
248 |
-
except requests.exceptions.RequestException as e:
|
249 |
-
logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
|
250 |
except Exception as e:
|
251 |
-
logger.error(f"Error inesperado descargando {url[:80]}
|
252 |
-
|
253 |
-
return None
|
254 |
|
255 |
def loop_audio_to_length(audio_clip, target_duration):
|
256 |
logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
|
257 |
-
|
258 |
if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
|
259 |
logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
|
260 |
try:
|
@@ -276,7 +282,6 @@ def loop_audio_to_length(audio_clip, target_duration):
|
|
276 |
|
277 |
loops = math.ceil(target_duration / audio_clip.duration)
|
278 |
logger.debug(f"Creando {loops} loops de audio")
|
279 |
-
|
280 |
audio_segments = [audio_clip] * loops
|
281 |
looped_audio = None
|
282 |
final_looped_audio = None
|
@@ -292,9 +297,8 @@ def loop_audio_to_length(audio_clip, target_duration):
|
|
292 |
raise ValueError("Invalid final subclipped audio.")
|
293 |
|
294 |
return final_looped_audio
|
295 |
-
|
296 |
except Exception as e:
|
297 |
-
logger.error(f"Error concatenating/subclipping audio clips for looping: {
|
298 |
try:
|
299 |
if audio_clip.duration is not None and audio_clip.duration > 0:
|
300 |
logger.warning("Returning original audio clip (may be too short).")
|
@@ -303,7 +307,6 @@ def loop_audio_to_length(audio_clip, target_duration):
|
|
303 |
pass
|
304 |
logger.error("Fallback to original audio clip failed.")
|
305 |
return AudioFileClip(filename="")
|
306 |
-
|
307 |
finally:
|
308 |
if looped_audio is not None and looped_audio is not final_looped_audio:
|
309 |
try: looped_audio.close()
|
@@ -323,7 +326,6 @@ def extract_visual_keywords_from_script(script_text):
|
|
323 |
logger.debug("Intentando extracción con KeyBERT...")
|
324 |
keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
|
325 |
keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
|
326 |
-
|
327 |
all_keywords = keywords1 + keywords2
|
328 |
all_keywords.sort(key=lambda item: item[1], reverse=True)
|
329 |
|
@@ -339,7 +341,6 @@ def extract_visual_keywords_from_script(script_text):
|
|
339 |
if keywords_list:
|
340 |
logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
|
341 |
return keywords_list
|
342 |
-
|
343 |
except Exception as e:
|
344 |
logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
|
345 |
|
@@ -348,14 +349,12 @@ def extract_visual_keywords_from_script(script_text):
|
|
348 |
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", "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus", "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á"}
|
349 |
|
350 |
valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
|
351 |
-
|
352 |
if not valid_words:
|
353 |
logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
|
354 |
return ["naturaleza", "ciudad", "paisaje"]
|
355 |
|
356 |
word_counts = Counter(valid_words)
|
357 |
top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
|
358 |
-
|
359 |
if not top_keywords:
|
360 |
logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
|
361 |
return ["naturaleza", "ciudad", "paisaje"]
|
@@ -369,13 +368,14 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
369 |
logger.debug(f"Input: '{input_text[:100]}...'")
|
370 |
logger.info(f"Voz seleccionada: {selected_voice}")
|
371 |
|
372 |
-
|
|
|
|
|
373 |
logger.warning(f"Voz seleccionada '{selected_voice}' no es válida. Usando voz por defecto: {DEFAULT_VOICE_ID}")
|
374 |
selected_voice = DEFAULT_VOICE_ID
|
375 |
|
376 |
start_time = datetime.now()
|
377 |
temp_dir_intermediate = None
|
378 |
-
|
379 |
audio_tts_original = None
|
380 |
musica_audio_original = None
|
381 |
audio_tts = None
|
@@ -384,6 +384,7 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
384 |
video_final = None
|
385 |
source_clips = []
|
386 |
clips_to_concatenate = []
|
|
|
387 |
|
388 |
try:
|
389 |
if prompt_type == "Generar Guion con IA":
|
@@ -392,37 +393,23 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
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 |
|
400 |
temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
|
401 |
logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
|
402 |
-
temp_intermediate_files = []
|
403 |
|
404 |
logger.info("Generando audio de voz...")
|
405 |
voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
for voice in voices_to_try:
|
411 |
-
if not voice: continue
|
412 |
-
logger.info(f"Intentando TTS con voz: {voice}...")
|
413 |
-
tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=voice))
|
414 |
-
if tts_success:
|
415 |
-
break
|
416 |
-
else:
|
417 |
-
logger.warning(f"Fallo al generar TTS con voz '{voice}'.")
|
418 |
-
|
419 |
if not tts_success:
|
420 |
raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
|
421 |
|
422 |
temp_intermediate_files.append(voz_path)
|
423 |
-
|
424 |
audio_tts_original = AudioFileClip(voz_path)
|
425 |
-
|
426 |
if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
|
427 |
logger.critical("Clip de audio TTS inicial es inválido.")
|
428 |
try: audio_tts_original.close()
|
@@ -432,7 +419,6 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
432 |
audio_tts = audio_tts_original
|
433 |
audio_duration = audio_tts_original.duration
|
434 |
logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
|
435 |
-
|
436 |
if audio_duration < 1.0:
|
437 |
logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
|
438 |
raise ValueError("Generated voice audio is too short.")
|
@@ -452,7 +438,6 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
452 |
videos_data = []
|
453 |
total_desired_videos = 10
|
454 |
per_page_per_keyword = max(1, total_desired_videos // len(keywords))
|
455 |
-
|
456 |
for keyword in keywords:
|
457 |
if len(videos_data) >= total_desired_videos: break
|
458 |
try:
|
@@ -484,14 +469,12 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
484 |
if 'video_files' not in video or not video['video_files']:
|
485 |
logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
|
486 |
continue
|
487 |
-
|
488 |
try:
|
489 |
best_quality = None
|
490 |
for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
|
491 |
if 'link' in vf:
|
492 |
best_quality = vf
|
493 |
break
|
494 |
-
|
495 |
if best_quality and 'link' in best_quality:
|
496 |
path = download_video_file(best_quality['link'], temp_dir_intermediate)
|
497 |
if path:
|
@@ -509,30 +492,24 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
509 |
current_duration = 0
|
510 |
min_clip_duration = 0.5
|
511 |
max_clip_segment = 10.0
|
512 |
-
|
513 |
for i, path in enumerate(video_paths):
|
514 |
if current_duration >= audio_duration + max_clip_segment:
|
515 |
logger.debug(f"Video base suficiente.")
|
516 |
break
|
517 |
-
|
518 |
clip = None
|
519 |
try:
|
520 |
logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
|
521 |
clip = VideoFileClip(path)
|
522 |
source_clips.append(clip)
|
523 |
-
|
524 |
if clip.reader is None or clip.duration is None or clip.duration <= 0:
|
525 |
logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido.")
|
526 |
continue
|
527 |
-
|
528 |
remaining_needed = audio_duration - current_duration
|
529 |
potential_use_duration = min(clip.duration, max_clip_segment)
|
530 |
-
|
531 |
if remaining_needed > 0:
|
532 |
segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
|
533 |
segment_duration = max(min_clip_duration, segment_duration)
|
534 |
segment_duration = min(segment_duration, clip.duration)
|
535 |
-
|
536 |
if segment_duration >= min_clip_duration:
|
537 |
try:
|
538 |
sub = clip.subclip(0, segment_duration)
|
@@ -541,19 +518,16 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
541 |
try: sub.close()
|
542 |
except: pass
|
543 |
continue
|
544 |
-
|
545 |
clips_to_concatenate.append(sub)
|
546 |
current_duration += sub.duration
|
547 |
except Exception as sub_e:
|
548 |
logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip: {str(sub_e)}")
|
549 |
continue
|
550 |
-
|
551 |
except Exception as e:
|
552 |
logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
|
553 |
continue
|
554 |
|
555 |
logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
|
556 |
-
|
557 |
if not clips_to_concatenate:
|
558 |
logger.error("No hay segmentos de video válidos disponibles.")
|
559 |
raise ValueError("No hay segmentos de video válidos.")
|
@@ -563,11 +537,9 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
563 |
try:
|
564 |
concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
|
565 |
logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
|
566 |
-
|
567 |
if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
|
568 |
logger.critical("Video base concatenado es inválido.")
|
569 |
raise ValueError("Fallo al crear video base válido.")
|
570 |
-
|
571 |
except Exception as e:
|
572 |
logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
|
573 |
raise ValueError("Fallo durante la concatenación de video inicial.")
|
@@ -579,12 +551,10 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
579 |
|
580 |
video_base = concatenated_base
|
581 |
final_video_base = video_base
|
582 |
-
|
583 |
if final_video_base.duration < audio_duration:
|
584 |
logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
|
585 |
num_full_repeats = int(audio_duration // final_video_base.duration)
|
586 |
remaining_duration = audio_duration % final_video_base.duration
|
587 |
-
|
588 |
repeated_clips_list = [final_video_base] * num_full_repeats
|
589 |
if remaining_duration > 0:
|
590 |
try:
|
@@ -604,17 +574,13 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
604 |
try:
|
605 |
video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
|
606 |
logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
|
607 |
-
|
608 |
if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
|
609 |
logger.critical("Video base repetido concatenado es inválido.")
|
610 |
raise ValueError("Fallo al crear video base repetido válido.")
|
611 |
-
|
612 |
if final_video_base is not video_base_repeated:
|
613 |
try: final_video_base.close()
|
614 |
except: pass
|
615 |
-
|
616 |
final_video_base = video_base_repeated
|
617 |
-
|
618 |
except Exception as e:
|
619 |
logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
|
620 |
raise ValueError("Fallo durante la repetición de video.")
|
@@ -632,13 +598,10 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
632 |
if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
|
633 |
logger.critical("Video base recortado es inválido.")
|
634 |
raise ValueError("Fallo al crear video base recortado válido.")
|
635 |
-
|
636 |
if final_video_base is not trimmed_video_base:
|
637 |
try: final_video_base.close()
|
638 |
except: pass
|
639 |
-
|
640 |
final_video_base = trimmed_video_base
|
641 |
-
|
642 |
except Exception as e:
|
643 |
logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
|
644 |
raise ValueError("Fallo durante el recorte de video.")
|
@@ -652,11 +615,9 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
652 |
raise ValueError("Video base final tiene tamaño inválido.")
|
653 |
|
654 |
video_base = final_video_base
|
655 |
-
|
656 |
logger.info("Procesando audio...")
|
657 |
final_audio = audio_tts_original
|
658 |
musica_audio_looped = None
|
659 |
-
|
660 |
if musica_file:
|
661 |
musica_audio_original = None
|
662 |
try:
|
@@ -664,9 +625,7 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
664 |
shutil.copyfile(musica_file, music_path)
|
665 |
temp_intermediate_files.append(music_path)
|
666 |
logger.info(f"Música de fondo copiada a: {music_path}")
|
667 |
-
|
668 |
musica_audio_original = AudioFileClip(music_path)
|
669 |
-
|
670 |
if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
|
671 |
logger.warning("Clip de música de fondo parece inválido.")
|
672 |
try: musica_audio_original.close()
|
@@ -675,19 +634,16 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
675 |
else:
|
676 |
musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
|
677 |
logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
|
678 |
-
|
679 |
if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
|
680 |
logger.warning("Clip de música de fondo loopeado es inválido.")
|
681 |
try: musica_audio_looped.close()
|
682 |
except: pass
|
683 |
musica_audio_looped = None
|
684 |
-
|
685 |
if musica_audio_looped:
|
686 |
composite_audio = CompositeAudioClip([
|
687 |
musica_audio_looped.volumex(0.2),
|
688 |
audio_tts_original.volumex(1.0)
|
689 |
])
|
690 |
-
|
691 |
if composite_audio.duration is None or composite_audio.duration <= 0:
|
692 |
logger.warning("Clip de audio compuesto es inválido.")
|
693 |
try: composite_audio.close()
|
@@ -697,7 +653,6 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
697 |
logger.info("Mezcla de audio completada (voz + música).")
|
698 |
final_audio = composite_audio
|
699 |
musica_audio = musica_audio_looped
|
700 |
-
|
701 |
except Exception as e:
|
702 |
logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
|
703 |
final_audio = audio_tts_original
|
@@ -723,7 +678,6 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
723 |
|
724 |
logger.info("Renderizando video final...")
|
725 |
video_final = video_base.set_audio(final_audio)
|
726 |
-
|
727 |
if video_final is None or video_final.duration is None or video_final.duration <= 0:
|
728 |
logger.critical("Clip de video final (con audio) es inválido.")
|
729 |
raise ValueError("Clip de video final es inválido.")
|
@@ -731,9 +685,7 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
731 |
output_filename = "final_video.mp4"
|
732 |
output_path = os.path.join(temp_dir_intermediate, output_filename)
|
733 |
logger.info(f"Escribiendo video final a: {output_path}")
|
734 |
-
|
735 |
video_final.write_videofile(
|
736 |
-
output_path,
|
737 |
fps=24,
|
738 |
threads=4,
|
739 |
codec="libx264",
|
@@ -744,9 +696,7 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
744 |
|
745 |
total_time = (datetime.now() - start_time).total_seconds()
|
746 |
logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
|
747 |
-
|
748 |
return output_path
|
749 |
-
|
750 |
except ValueError as ve:
|
751 |
logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
|
752 |
raise ve
|
@@ -808,33 +758,24 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
|
|
808 |
def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
|
809 |
logger.info("="*80)
|
810 |
logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
|
811 |
-
|
812 |
input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
|
813 |
-
|
814 |
output_video = None
|
815 |
output_file = None
|
816 |
status_msg = gr.update(value="⏳ Procesando...", interactive=False)
|
817 |
-
|
818 |
if not input_text or not input_text.strip():
|
819 |
logger.warning("Texto de entrada vacío.")
|
820 |
return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
|
821 |
-
|
822 |
-
|
|
|
|
|
823 |
logger.warning(f"Voz seleccionada inválida: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}")
|
824 |
selected_voice = DEFAULT_VOICE_ID
|
825 |
-
|
826 |
-
logger.info(f"Tipo de entrada: {prompt_type}")
|
827 |
-
logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
|
828 |
-
if musica_file:
|
829 |
-
logger.info(f"Archivo de música recibido: {musica_file}")
|
830 |
-
else:
|
831 |
-
logger.info("No se proporcionó archivo de música.")
|
832 |
logger.info(f"Voz final a usar (ID): {selected_voice}")
|
833 |
|
834 |
try:
|
835 |
logger.info("Llamando a crear_video...")
|
836 |
video_path = crear_video(prompt_type, input_text, selected_voice, musica_file)
|
837 |
-
|
838 |
if video_path and os.path.exists(video_path):
|
839 |
logger.info(f"crear_video retornó path: {video_path}")
|
840 |
output_video = video_path
|
@@ -842,23 +783,20 @@ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
|
|
842 |
status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
|
843 |
else:
|
844 |
logger.error(f"crear_video no retornó un path válido: {video_path}")
|
845 |
-
status_msg = gr.update(value="❌ Error: La generación del video
|
846 |
-
|
847 |
except ValueError as ve:
|
848 |
-
logger.warning(f"Error de validación: {str(ve)}")
|
849 |
status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
|
850 |
except Exception as e:
|
851 |
-
logger.critical(f"Error crítico: {str(e)}", exc_info=True)
|
852 |
status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
|
853 |
-
|
854 |
-
logger.info("Fin del handler run_app.")
|
855 |
-
return output_video, output_file, status_msg
|
856 |
|
|
|
857 |
with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
|
858 |
.gradio-container {max-width: 800px; margin: auto;}
|
859 |
h1 {text-align: center;}
|
860 |
""") as app:
|
861 |
-
|
862 |
gr.Markdown("# 🎬 Generador Automático de Videos con IA")
|
863 |
gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
|
864 |
|
@@ -869,41 +807,36 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
|
|
869 |
label="Método de Entrada",
|
870 |
value="Generar Guion con IA"
|
871 |
)
|
872 |
-
|
873 |
with gr.Column(visible=True) as ia_guion_column:
|
874 |
prompt_ia = gr.Textbox(
|
875 |
label="Tema para IA",
|
876 |
lines=2,
|
877 |
-
placeholder="Ej: Un paisaje natural con montañas y ríos al amanecer...",
|
878 |
max_lines=4,
|
879 |
value=""
|
880 |
)
|
881 |
-
|
882 |
with gr.Column(visible=False) as manual_guion_column:
|
883 |
prompt_manual = gr.Textbox(
|
884 |
label="Tu Guion Completo",
|
885 |
lines=5,
|
886 |
-
placeholder="Ej: En este video exploraremos los misterios del océano
|
887 |
max_lines=10,
|
888 |
value=""
|
889 |
)
|
890 |
-
|
891 |
musica_input = gr.Audio(
|
892 |
label="Música de fondo (opcional)",
|
893 |
type="filepath",
|
894 |
interactive=True,
|
895 |
value=None
|
896 |
)
|
897 |
-
|
898 |
voice_dropdown = gr.Dropdown(
|
899 |
label="Seleccionar Voz para Guion",
|
900 |
-
choices=
|
901 |
-
value=
|
902 |
interactive=True
|
903 |
)
|
904 |
-
|
905 |
generate_btn = gr.Button("✨ Generar Video", variant="primary")
|
906 |
-
|
907 |
with gr.Column():
|
908 |
video_output = gr.Video(
|
909 |
label="Previsualización del Video Generado",
|
@@ -929,7 +862,6 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
|
|
929 |
inputs=prompt_type,
|
930 |
outputs=[ia_guion_column, manual_guion_column]
|
931 |
)
|
932 |
-
|
933 |
generate_btn.click(
|
934 |
lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
|
935 |
outputs=[video_output, file_output, status_output],
|
@@ -946,14 +878,14 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
|
|
946 |
|
947 |
gr.Markdown("### Instrucciones:")
|
948 |
gr.Markdown("""
|
949 |
-
1.
|
950 |
-
2.
|
951 |
-
3.
|
952 |
-
4.
|
953 |
-
5.
|
954 |
-
6.
|
955 |
-
7.
|
956 |
-
8.
|
957 |
""")
|
958 |
gr.Markdown("---")
|
959 |
gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
|
@@ -965,12 +897,11 @@ if __name__ == "__main__":
|
|
965 |
try:
|
966 |
temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
|
967 |
temp_clip.close()
|
968 |
-
logger.info("Clips base de MoviePy creados y cerrados exitosamente.")
|
969 |
except Exception as e:
|
970 |
-
logger.critical(f"Fallo al crear clip base de MoviePy: {e}", exc_info=True)
|
971 |
except Exception as e:
|
972 |
-
logger.critical(f"Fallo al importar MoviePy: {e}", exc_info=True)
|
973 |
-
|
974 |
logger.info("Iniciando aplicación Gradio...")
|
975 |
try:
|
976 |
app.launch(server_name="0.0.0.0", server_port=7860, share=False)
|
|
|
15 |
import shutil
|
16 |
import json
|
17 |
from collections import Counter
|
18 |
+
import time
|
19 |
|
20 |
# Configuración de logging
|
21 |
logging.basicConfig(
|
|
|
31 |
logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
|
32 |
logger.info("="*80)
|
33 |
|
34 |
+
# CAMBIO: Lista estática de voces como en script 2
|
35 |
+
VOICES = {
|
36 |
+
"Español": [
|
37 |
+
"es-ES-JuanNeural", "es-ES-ElviraNeural", "es-MX-JorgeNeural", "es-MX-DaliaNeural",
|
38 |
+
"es-AR-TomasNeural", "es-AR-ElenaNeural", "es-CO-GonzaloNeural", "es-CO-SalomeNeural",
|
39 |
+
"es-CL-LorenzoNeural", "es-CL-CatalinaNeural", "es-PE-AlexNeural", "es-PE-CamilaNeural",
|
40 |
+
"es-VE-PaolaNeural", "es-VE-SebastianNeural", "es-US-AlonsoNeural", "es-US-PalomaNeural"
|
41 |
+
]
|
42 |
+
}
|
43 |
+
|
44 |
+
# CAMBIO: Función para obtener las voces en formato de dropdown como en script 2
|
45 |
+
def get_voice_choices():
|
46 |
+
choices = []
|
47 |
+
for region, voice_list in VOICES.items():
|
48 |
+
for voice_id in voice_list:
|
49 |
+
choices.append((f"{voice_id} ({region})", voice_id))
|
50 |
+
return choices
|
51 |
+
|
52 |
+
# CAMBIO: Usamos lista estática en lugar de edge_tts.list_voices()
|
53 |
+
AVAILABLE_VOICES = get_voice_choices()
|
54 |
+
DEFAULT_VOICE_ID = "es-ES-JuanNeural"
|
55 |
+
DEFAULT_VOICE_NAME = next((name for name, vid in AVAILABLE_VOICES if vid == DEFAULT_VOICE_ID), DEFAULT_VOICE_ID)
|
|
|
|
|
|
|
|
|
56 |
logger.info(f"Voz por defecto seleccionada: {DEFAULT_VOICE_NAME} ({DEFAULT_VOICE_ID})")
|
57 |
|
58 |
# Clave API de Pexels
|
|
|
60 |
if not PEXELS_API_KEY:
|
61 |
logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
|
62 |
|
63 |
+
# Inicialización de modelos con reintentos (como en script 1b)
|
64 |
MODEL_NAME = "datificate/gpt2-small-spanish"
|
65 |
logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
|
66 |
tokenizer = None
|
67 |
model = None
|
68 |
+
MAX_RETRIES = 3
|
69 |
+
for attempt in range(MAX_RETRIES):
|
70 |
+
try:
|
71 |
+
tokenizer = GPT2Tokenizer.from_pretrained(MODEL_NAME, timeout=60)
|
72 |
+
model = GPT2LMHeadModel.from_pretrained(MODEL_NAME, timeout=60).eval()
|
73 |
+
if tokenizer.pad_token is None:
|
74 |
+
tokenizer.pad_token = tokenizer.eos_token
|
75 |
+
logger.info(f"Modelo GPT-2 cargado | Vocabulario: {len(tokenizer)} tokens")
|
76 |
+
break
|
77 |
+
except Exception as e:
|
78 |
+
logger.warning(f"Intento {attempt + 1} falló al cargar GPT-2: {str(e)}")
|
79 |
+
if attempt < MAX_RETRIES - 1:
|
80 |
+
time.sleep(5)
|
81 |
+
else:
|
82 |
+
logger.error(f"FALLA CRÍTICA al cargar GPT-2 después de {MAX_RETRIES} intentos: {str(e)}")
|
83 |
+
tokenizer = model = None
|
84 |
|
85 |
logger.info("Cargando modelo KeyBERT...")
|
86 |
kw_model = None
|
|
|
98 |
|
99 |
logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
|
100 |
headers = {"Authorization": api_key}
|
101 |
+
for attempt in range(MAX_RETRIES):
|
102 |
+
try:
|
103 |
+
params = {
|
104 |
+
"query": query,
|
105 |
+
"per_page": per_page,
|
106 |
+
"orientation": "landscape",
|
107 |
+
"size": "medium"
|
108 |
+
}
|
109 |
+
response = requests.get(
|
110 |
+
"https://api.pexels.com/videos/search",
|
111 |
+
headers=headers,
|
112 |
+
params=params,
|
113 |
+
timeout=60
|
114 |
+
)
|
115 |
+
response.raise_for_status()
|
116 |
+
data = response.json()
|
117 |
+
videos = data.get('videos', [])
|
118 |
+
logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
|
119 |
+
return videos
|
120 |
+
except requests.exceptions.RequestException as e:
|
121 |
+
logger.warning(f"Intento {attempt + 1} falló en Pexels: {str(e)}")
|
122 |
+
if attempt < MAX_RETRIES - 1:
|
123 |
+
time.sleep(5)
|
124 |
+
else:
|
125 |
+
logger.error(f"Error de conexión Pexels para '{query}' después de {MAX_RETRIES} intentos: {str(e)}")
|
126 |
+
return []
|
|
|
|
|
|
|
127 |
|
128 |
def generate_script(prompt, max_length=150):
|
129 |
logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
|
|
|
231 |
file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
|
232 |
output_path = os.path.join(temp_dir, file_name)
|
233 |
|
234 |
+
for attempt in range(MAX_RETRIES):
|
235 |
+
try:
|
236 |
+
with requests.get(url, stream=True, timeout=60) as r:
|
237 |
+
r.raise_for_status()
|
238 |
+
with open(output_path, 'wb') as f:
|
239 |
+
for chunk in r.iter_content(chunk_size=8192):
|
240 |
+
f.write(chunk)
|
241 |
+
|
242 |
+
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
|
243 |
+
logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
|
244 |
+
return output_path
|
245 |
+
else:
|
246 |
+
logger.warning(f"Descarga parece incompleta o vacía para {url[:80]}...")
|
247 |
+
if os.path.exists(output_path):
|
248 |
+
os.remove(output_path)
|
249 |
+
return None
|
250 |
+
except requests.exceptions.RequestException as e:
|
251 |
+
logger.warning(f"Intento {attempt + 1} falló al descargar video: {str(e)}")
|
252 |
+
if attempt < MAX_RETRIES - 1:
|
253 |
+
time.sleep(5)
|
254 |
+
else:
|
255 |
+
logger.error(f"Error descargando video después de {MAX_RETRIES} intentos: {str(e)}")
|
256 |
+
return None
|
257 |
|
|
|
|
|
258 |
except Exception as e:
|
259 |
+
logger.error(f"Error inesperado descargando {url[:80]}...: {str(e)}", exc_info=True)
|
260 |
+
return None
|
|
|
261 |
|
262 |
def loop_audio_to_length(audio_clip, target_duration):
|
263 |
logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
|
|
|
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 |
try:
|
|
|
282 |
|
283 |
loops = math.ceil(target_duration / audio_clip.duration)
|
284 |
logger.debug(f"Creando {loops} loops de audio")
|
|
|
285 |
audio_segments = [audio_clip] * loops
|
286 |
looped_audio = None
|
287 |
final_looped_audio = None
|
|
|
297 |
raise ValueError("Invalid final subclipped audio.")
|
298 |
|
299 |
return final_looped_audio
|
|
|
300 |
except Exception as e:
|
301 |
+
logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
|
302 |
try:
|
303 |
if audio_clip.duration is not None and audio_clip.duration > 0:
|
304 |
logger.warning("Returning original audio clip (may be too short).")
|
|
|
307 |
pass
|
308 |
logger.error("Fallback to original audio clip failed.")
|
309 |
return AudioFileClip(filename="")
|
|
|
310 |
finally:
|
311 |
if looped_audio is not None and looped_audio is not final_looped_audio:
|
312 |
try: looped_audio.close()
|
|
|
326 |
logger.debug("Intentando extracción con KeyBERT...")
|
327 |
keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
|
328 |
keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
|
|
|
329 |
all_keywords = keywords1 + keywords2
|
330 |
all_keywords.sort(key=lambda item: item[1], reverse=True)
|
331 |
|
|
|
341 |
if keywords_list:
|
342 |
logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
|
343 |
return keywords_list
|
|
|
344 |
except Exception as e:
|
345 |
logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
|
346 |
|
|
|
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", "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus", "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á"}
|
350 |
|
351 |
valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
|
|
|
352 |
if not valid_words:
|
353 |
logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
|
354 |
return ["naturaleza", "ciudad", "paisaje"]
|
355 |
|
356 |
word_counts = Counter(valid_words)
|
357 |
top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
|
|
|
358 |
if not top_keywords:
|
359 |
logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
|
360 |
return ["naturaleza", "ciudad", "paisaje"]
|
|
|
368 |
logger.debug(f"Input: '{input_text[:100]}...'")
|
369 |
logger.info(f"Voz seleccionada: {selected_voice}")
|
370 |
|
371 |
+
# CAMBIO: Validación simple de la voz como en script 2
|
372 |
+
voice_ids = [vid for _, vid in AVAILABLE_VOICES]
|
373 |
+
if selected_voice not in voice_ids:
|
374 |
logger.warning(f"Voz seleccionada '{selected_voice}' no es válida. Usando voz por defecto: {DEFAULT_VOICE_ID}")
|
375 |
selected_voice = DEFAULT_VOICE_ID
|
376 |
|
377 |
start_time = datetime.now()
|
378 |
temp_dir_intermediate = None
|
|
|
379 |
audio_tts_original = None
|
380 |
musica_audio_original = None
|
381 |
audio_tts = None
|
|
|
384 |
video_final = None
|
385 |
source_clips = []
|
386 |
clips_to_concatenate = []
|
387 |
+
temp_intermediate_files = []
|
388 |
|
389 |
try:
|
390 |
if prompt_type == "Generar Guion con IA":
|
|
|
393 |
guion = input_text.strip()
|
394 |
|
395 |
logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
|
|
|
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 |
|
400 |
temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
|
401 |
logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
|
|
|
402 |
|
403 |
logger.info("Generando audio de voz...")
|
404 |
voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
|
405 |
+
# CAMBIO: Usamos la voz seleccionada directamente, sin fallbacks complejos
|
406 |
+
logger.info(f"Intentando TTS con voz: {selected_voice}...")
|
407 |
+
tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=selected_voice))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
408 |
if not tts_success:
|
409 |
raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
|
410 |
|
411 |
temp_intermediate_files.append(voz_path)
|
|
|
412 |
audio_tts_original = AudioFileClip(voz_path)
|
|
|
413 |
if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
|
414 |
logger.critical("Clip de audio TTS inicial es inválido.")
|
415 |
try: audio_tts_original.close()
|
|
|
419 |
audio_tts = audio_tts_original
|
420 |
audio_duration = audio_tts_original.duration
|
421 |
logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
|
|
|
422 |
if audio_duration < 1.0:
|
423 |
logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
|
424 |
raise ValueError("Generated voice audio is too short.")
|
|
|
438 |
videos_data = []
|
439 |
total_desired_videos = 10
|
440 |
per_page_per_keyword = max(1, total_desired_videos // len(keywords))
|
|
|
441 |
for keyword in keywords:
|
442 |
if len(videos_data) >= total_desired_videos: break
|
443 |
try:
|
|
|
469 |
if 'video_files' not in video or not video['video_files']:
|
470 |
logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
|
471 |
continue
|
|
|
472 |
try:
|
473 |
best_quality = None
|
474 |
for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
|
475 |
if 'link' in vf:
|
476 |
best_quality = vf
|
477 |
break
|
|
|
478 |
if best_quality and 'link' in best_quality:
|
479 |
path = download_video_file(best_quality['link'], temp_dir_intermediate)
|
480 |
if path:
|
|
|
492 |
current_duration = 0
|
493 |
min_clip_duration = 0.5
|
494 |
max_clip_segment = 10.0
|
|
|
495 |
for i, path in enumerate(video_paths):
|
496 |
if current_duration >= audio_duration + max_clip_segment:
|
497 |
logger.debug(f"Video base suficiente.")
|
498 |
break
|
|
|
499 |
clip = None
|
500 |
try:
|
501 |
logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
|
502 |
clip = VideoFileClip(path)
|
503 |
source_clips.append(clip)
|
|
|
504 |
if clip.reader is None or clip.duration is None or clip.duration <= 0:
|
505 |
logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido.")
|
506 |
continue
|
|
|
507 |
remaining_needed = audio_duration - current_duration
|
508 |
potential_use_duration = min(clip.duration, max_clip_segment)
|
|
|
509 |
if remaining_needed > 0:
|
510 |
segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
|
511 |
segment_duration = max(min_clip_duration, segment_duration)
|
512 |
segment_duration = min(segment_duration, clip.duration)
|
|
|
513 |
if segment_duration >= min_clip_duration:
|
514 |
try:
|
515 |
sub = clip.subclip(0, segment_duration)
|
|
|
518 |
try: sub.close()
|
519 |
except: pass
|
520 |
continue
|
|
|
521 |
clips_to_concatenate.append(sub)
|
522 |
current_duration += sub.duration
|
523 |
except Exception as sub_e:
|
524 |
logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip: {str(sub_e)}")
|
525 |
continue
|
|
|
526 |
except Exception as e:
|
527 |
logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
|
528 |
continue
|
529 |
|
530 |
logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
|
|
|
531 |
if not clips_to_concatenate:
|
532 |
logger.error("No hay segmentos de video válidos disponibles.")
|
533 |
raise ValueError("No hay segmentos de video válidos.")
|
|
|
537 |
try:
|
538 |
concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
|
539 |
logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
|
|
|
540 |
if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
|
541 |
logger.critical("Video base concatenado es inválido.")
|
542 |
raise ValueError("Fallo al crear video base válido.")
|
|
|
543 |
except Exception as e:
|
544 |
logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
|
545 |
raise ValueError("Fallo durante la concatenación de video inicial.")
|
|
|
551 |
|
552 |
video_base = concatenated_base
|
553 |
final_video_base = video_base
|
|
|
554 |
if final_video_base.duration < audio_duration:
|
555 |
logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
|
556 |
num_full_repeats = int(audio_duration // final_video_base.duration)
|
557 |
remaining_duration = audio_duration % final_video_base.duration
|
|
|
558 |
repeated_clips_list = [final_video_base] * num_full_repeats
|
559 |
if remaining_duration > 0:
|
560 |
try:
|
|
|
574 |
try:
|
575 |
video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
|
576 |
logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
|
|
|
577 |
if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
|
578 |
logger.critical("Video base repetido concatenado es inválido.")
|
579 |
raise ValueError("Fallo al crear video base repetido válido.")
|
|
|
580 |
if final_video_base is not video_base_repeated:
|
581 |
try: final_video_base.close()
|
582 |
except: pass
|
|
|
583 |
final_video_base = video_base_repeated
|
|
|
584 |
except Exception as e:
|
585 |
logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
|
586 |
raise ValueError("Fallo durante la repetición de video.")
|
|
|
598 |
if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
|
599 |
logger.critical("Video base recortado es inválido.")
|
600 |
raise ValueError("Fallo al crear video base recortado válido.")
|
|
|
601 |
if final_video_base is not trimmed_video_base:
|
602 |
try: final_video_base.close()
|
603 |
except: pass
|
|
|
604 |
final_video_base = trimmed_video_base
|
|
|
605 |
except Exception as e:
|
606 |
logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
|
607 |
raise ValueError("Fallo durante el recorte de video.")
|
|
|
615 |
raise ValueError("Video base final tiene tamaño inválido.")
|
616 |
|
617 |
video_base = final_video_base
|
|
|
618 |
logger.info("Procesando audio...")
|
619 |
final_audio = audio_tts_original
|
620 |
musica_audio_looped = None
|
|
|
621 |
if musica_file:
|
622 |
musica_audio_original = None
|
623 |
try:
|
|
|
625 |
shutil.copyfile(musica_file, music_path)
|
626 |
temp_intermediate_files.append(music_path)
|
627 |
logger.info(f"Música de fondo copiada a: {music_path}")
|
|
|
628 |
musica_audio_original = AudioFileClip(music_path)
|
|
|
629 |
if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
|
630 |
logger.warning("Clip de música de fondo parece inválido.")
|
631 |
try: musica_audio_original.close()
|
|
|
634 |
else:
|
635 |
musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
|
636 |
logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
|
|
|
637 |
if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
|
638 |
logger.warning("Clip de música de fondo loopeado es inválido.")
|
639 |
try: musica_audio_looped.close()
|
640 |
except: pass
|
641 |
musica_audio_looped = None
|
|
|
642 |
if musica_audio_looped:
|
643 |
composite_audio = CompositeAudioClip([
|
644 |
musica_audio_looped.volumex(0.2),
|
645 |
audio_tts_original.volumex(1.0)
|
646 |
])
|
|
|
647 |
if composite_audio.duration is None or composite_audio.duration <= 0:
|
648 |
logger.warning("Clip de audio compuesto es inválido.")
|
649 |
try: composite_audio.close()
|
|
|
653 |
logger.info("Mezcla de audio completada (voz + música).")
|
654 |
final_audio = composite_audio
|
655 |
musica_audio = musica_audio_looped
|
|
|
656 |
except Exception as e:
|
657 |
logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
|
658 |
final_audio = audio_tts_original
|
|
|
678 |
|
679 |
logger.info("Renderizando video final...")
|
680 |
video_final = video_base.set_audio(final_audio)
|
|
|
681 |
if video_final is None or video_final.duration is None or video_final.duration <= 0:
|
682 |
logger.critical("Clip de video final (con audio) es inválido.")
|
683 |
raise ValueError("Clip de video final es inválido.")
|
|
|
685 |
output_filename = "final_video.mp4"
|
686 |
output_path = os.path.join(temp_dir_intermediate, output_filename)
|
687 |
logger.info(f"Escribiendo video final a: {output_path}")
|
|
|
688 |
video_final.write_videofile(
|
|
|
689 |
fps=24,
|
690 |
threads=4,
|
691 |
codec="libx264",
|
|
|
696 |
|
697 |
total_time = (datetime.now() - start_time).total_seconds()
|
698 |
logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
|
|
|
699 |
return output_path
|
|
|
700 |
except ValueError as ve:
|
701 |
logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
|
702 |
raise ve
|
|
|
758 |
def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
|
759 |
logger.info("="*80)
|
760 |
logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
|
|
|
761 |
input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
|
|
|
762 |
output_video = None
|
763 |
output_file = None
|
764 |
status_msg = gr.update(value="⏳ Procesando...", interactive=False)
|
|
|
765 |
if not input_text or not input_text.strip():
|
766 |
logger.warning("Texto de entrada vacío.")
|
767 |
return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
|
768 |
+
|
769 |
+
# CAMBIO: Validación simple de la voz en run_app
|
770 |
+
voice_ids = [vid for _, vid in AVAILABLE_VOICES]
|
771 |
+
if selected_voice not in voice_ids:
|
772 |
logger.warning(f"Voz seleccionada inválida: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}")
|
773 |
selected_voice = DEFAULT_VOICE_ID
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
774 |
logger.info(f"Voz final a usar (ID): {selected_voice}")
|
775 |
|
776 |
try:
|
777 |
logger.info("Llamando a crear_video...")
|
778 |
video_path = crear_video(prompt_type, input_text, selected_voice, musica_file)
|
|
|
779 |
if video_path and os.path.exists(video_path):
|
780 |
logger.info(f"crear_video retornó path: {video_path}")
|
781 |
output_video = video_path
|
|
|
783 |
status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
|
784 |
else:
|
785 |
logger.error(f"crear_video no retornó un path válido: {video_path}")
|
786 |
+
status_msg = gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
|
|
|
787 |
except ValueError as ve:
|
788 |
+
logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
|
789 |
status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
|
790 |
except Exception as e:
|
791 |
+
logger.critical(f"Error crítico durante la creación del video: {str(e)}", exc_info=True)
|
792 |
status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
|
793 |
+
return output_video, output_file, status_msg
|
|
|
|
|
794 |
|
795 |
+
# Interfaz de Gradio
|
796 |
with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
|
797 |
.gradio-container {max-width: 800px; margin: auto;}
|
798 |
h1 {text-align: center;}
|
799 |
""") as app:
|
|
|
800 |
gr.Markdown("# 🎬 Generador Automático de Videos con IA")
|
801 |
gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
|
802 |
|
|
|
807 |
label="Método de Entrada",
|
808 |
value="Generar Guion con IA"
|
809 |
)
|
|
|
810 |
with gr.Column(visible=True) as ia_guion_column:
|
811 |
prompt_ia = gr.Textbox(
|
812 |
label="Tema para IA",
|
813 |
lines=2,
|
814 |
+
placeholder="Ej: Un paisaje natural con montañas y ríos al amanecer, mostrando la belleza de la naturaleza...",
|
815 |
max_lines=4,
|
816 |
value=""
|
817 |
)
|
|
|
818 |
with gr.Column(visible=False) as manual_guion_column:
|
819 |
prompt_manual = gr.Textbox(
|
820 |
label="Tu Guion Completo",
|
821 |
lines=5,
|
822 |
+
placeholder="Ej: En este video exploraremos los misterios del océano. Veremos la vida marina fascinante y los arrecifes de coral vibrantes. ¡Acompáñanos en esta aventura subacuática!",
|
823 |
max_lines=10,
|
824 |
value=""
|
825 |
)
|
|
|
826 |
musica_input = gr.Audio(
|
827 |
label="Música de fondo (opcional)",
|
828 |
type="filepath",
|
829 |
interactive=True,
|
830 |
value=None
|
831 |
)
|
832 |
+
# CAMBIO: Dropdown de voces simplificado
|
833 |
voice_dropdown = gr.Dropdown(
|
834 |
label="Seleccionar Voz para Guion",
|
835 |
+
choices=AVAILABLE_VOICES,
|
836 |
+
value=DEFAULT_VOICE_ID,
|
837 |
interactive=True
|
838 |
)
|
|
|
839 |
generate_btn = gr.Button("✨ Generar Video", variant="primary")
|
|
|
840 |
with gr.Column():
|
841 |
video_output = gr.Video(
|
842 |
label="Previsualización del Video Generado",
|
|
|
862 |
inputs=prompt_type,
|
863 |
outputs=[ia_guion_column, manual_guion_column]
|
864 |
)
|
|
|
865 |
generate_btn.click(
|
866 |
lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
|
867 |
outputs=[video_output, file_output, status_output],
|
|
|
878 |
|
879 |
gr.Markdown("### Instrucciones:")
|
880 |
gr.Markdown("""
|
881 |
+
1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
|
882 |
+
2. **Selecciona el tipo de entrada**: "Generar Guion con IA" o "Usar Mi Guion".
|
883 |
+
3. **Sube música** (opcional): Selecciona un archivo de audio (MP3, WAV, etc.).
|
884 |
+
4. **Selecciona la voz** deseada del desplegable.
|
885 |
+
5. **Haz clic en "✨ Generar Video"**.
|
886 |
+
6. Espera a que se procese el video. Verás el estado.
|
887 |
+
7. La previsualización aparecerá si es posible, y siempre un enlace **Descargar Archivo de Video** se mostrará si la generación fue exitosa.
|
888 |
+
8. Revisa `video_generator_full.log` para detalles si hay errores.
|
889 |
""")
|
890 |
gr.Markdown("---")
|
891 |
gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
|
|
|
897 |
try:
|
898 |
temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
|
899 |
temp_clip.close()
|
900 |
+
logger.info("Clips base de MoviePy creados y cerrados exitosamente. FFmpeg parece accesible.")
|
901 |
except Exception as e:
|
902 |
+
logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
|
903 |
except Exception as e:
|
904 |
+
logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
|
|
|
905 |
logger.info("Iniciando aplicación Gradio...")
|
906 |
try:
|
907 |
app.launch(server_name="0.0.0.0", server_port=7860, share=False)
|