Spaces:
Running
Running
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...")
|