gnosticdev commited on
Commit
a567f70
·
verified ·
1 Parent(s): 66a8f1d

Update app.py

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