gnosticdev commited on
Commit
f1f8e2a
·
verified ·
1 Parent(s): 798c552

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +435 -370
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import asyncio
2
  import logging
3
  import tempfile
@@ -8,14 +9,15 @@ import gradio as gr
8
  import torch
9
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
10
  from keybert import KeyBERT
11
- import subprocess
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
  logging.basicConfig(
20
  level=logging.DEBUG,
21
  format='%(asctime)s - %(levelname)s - %(message)s',
@@ -28,87 +30,14 @@ logger = logging.getLogger(__name__)
28
  logger.info("="*80)
29
  logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
30
  logger.info("="*80)
31
- VOCES_DISPONIBLES = {
32
- "Español (España)": {
33
- "es-ES-JuanNeural": "Juan (España) - Masculino",
34
- "es-ES-ElviraNeural": "Elvira (España) - Femenino",
35
- "es-ES-AlvaroNeural": "Álvaro (España) - Masculino",
36
- "es-ES-AbrilNeural": "Abril (España) - Femenino",
37
- "es-ES-ArnauNeural": "Arnau (España) - Masculino",
38
- "es-ES-DarioNeural": "Darío (España) - Masculino",
39
- "es-ES-EliasNeural": "Elías (España) - Masculino",
40
- "es-ES-EstrellaNeural": "Estrella (España) - Femenino",
41
- "es-ES-IreneNeural": "Irene (España) - Femenino",
42
- "es-ES-LaiaNeural": "Laia (España) - Femenino",
43
- "es-ES-LiaNeural": "Lía (España) - Femenino",
44
- "es-ES-NilNeural": "Nil (España) - Masculino",
45
- "es-ES-SaulNeural": "Saúl (España) - Masculino",
46
- "es-ES-TeoNeural": "Teo (España) - Masculino",
47
- "es-ES-TrianaNeural": "Triana (España) - Femenino",
48
- "es-ES-VeraNeural": "Vera (España) - Femenino"
49
- },
50
- "Español (México)": {
51
- "es-MX-JorgeNeural": "Jorge (México) - Masculino",
52
- "es-MX-DaliaNeural": "Dalia (México) - Femenino",
53
- "es-MX-BeatrizNeural": "Beatriz (México) - Femenino",
54
- "es-MX-CandelaNeural": "Candela (México) - Femenino",
55
- "es-MX-CarlotaNeural": "Carlota (México) - Femenino",
56
- "es-MX-CecilioNeural": "Cecilio (México) - Masculino",
57
- "es-MX-GerardoNeural": "Gerardo (México) - Masculino",
58
- "es-MX-LarissaNeural": "Larissa (México) - Femenino",
59
- "es-MX-LibertoNeural": "Liberto (México) - Masculino",
60
- "es-MX-LucianoNeural": "Luciano (México) - Masculino",
61
- "es-MX-MarinaNeural": "Marina (México) - Femenino",
62
- "es-MX-NuriaNeural": "Nuria (México) - Femenino",
63
- "es-MX-PelayoNeural": "Pelayo (México) - Masculino",
64
- "es-MX-RenataNeural": "Renata (México) - Femenino",
65
- "es-MX-YagoNeural": "Yago (México) - Masculino"
66
- },
67
- "Español (Argentina)": {
68
- "es-AR-TomasNeural": "Tomás (Argentina) - Masculino",
69
- "es-AR-ElenaNeural": "Elena (Argentina) - Femenino"
70
- },
71
- "Español (Colombia)": {
72
- "es-CO-GonzaloNeural": "Gonzalo (Colombia) - Masculino",
73
- "es-CO-SalomeNeural": "Salomé (Colombia) - Femenino"
74
- },
75
- "Español (Chile)": {
76
- "es-CL-LorenzoNeural": "Lorenzo (Chile) - Masculino",
77
- "es-CL-CatalinaNeural": "Catalina (Chile) - Femenino"
78
- },
79
- "Español (Perú)": {
80
- "es-PE-AlexNeural": "Alex (Perú) - Masculino",
81
- "es-PE-CamilaNeural": "Camila (Perú) - Femenino"
82
- },
83
- "Español (Venezuela)": {
84
- "es-VE-PaolaNeural": "Paola (Venezuela) - Femenino",
85
- "es-VE-SebastianNeural": "Sebastián (Venezuela) - Masculino"
86
- },
87
- "Español (Estados Unidos)": {
88
- "es-US-AlonsoNeural": "Alonso (Estados Unidos) - Masculino",
89
- "es-US-PalomaNeural": "Paloma (Estados Unidos) - Femenino"
90
- }
91
- }
92
- def get_voice_choices():
93
- choices = []
94
- for region, voices in VOCES_DISPONIBLES.items():
95
- for voice_id, voice_name in voices.items():
96
- choices.append((f"{voice_name} ({region})", voice_id))
97
- return choices
98
- AVAILABLE_VOICES = get_voice_choices()
99
- DEFAULT_VOICE_ID = "es-ES-JuanNeural"
100
- DEFAULT_VOICE_NAME = DEFAULT_VOICE_ID
101
- for text, voice_id in AVAILABLE_VOICES:
102
- if voice_id == DEFAULT_VOICE_ID:
103
- DEFAULT_VOICE_NAME = text
104
- break
105
- if DEFAULT_VOICE_ID not in [v[1] for v in AVAILABLE_VOICES]:
106
- DEFAULT_VOICE_ID = AVAILABLE_VOICES[0][1] if AVAILABLE_VOICES else "en-US-AriaNeural"
107
- DEFAULT_VOICE_NAME = AVAILABLE_VOICES[0][0] if AVAILABLE_VOICES else "Aria (United States) - Female"
108
- logger.info(f"Voz por defecto seleccionada (ID): {DEFAULT_VOICE_ID}")
109
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
110
  if not PEXELS_API_KEY:
111
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
 
 
 
112
  MODEL_NAME = "datificate/gpt2-small-spanish"
113
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
114
  tokenizer = None
@@ -122,6 +51,7 @@ try:
122
  except Exception as e:
123
  logger.error(f"FALLA CRÍTICA al cargar GPT-2: {str(e)}", exc_info=True)
124
  tokenizer = model = None
 
125
  logger.info("Cargando modelo KeyBERT...")
126
  kw_model = None
127
  try:
@@ -130,10 +60,12 @@ try:
130
  except Exception as e:
131
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
132
  kw_model = None
 
133
  def buscar_videos_pexels(query, api_key, per_page=5):
134
  if not api_key:
135
  logger.warning("No se puede buscar en Pexels: API Key no configurada.")
136
  return []
 
137
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
138
  headers = {"Authorization": api_key}
139
  try:
@@ -143,6 +75,7 @@ def buscar_videos_pexels(query, api_key, per_page=5):
143
  "orientation": "landscape",
144
  "size": "medium"
145
  }
 
146
  response = requests.get(
147
  "https://api.pexels.com/videos/search",
148
  headers=headers,
@@ -150,29 +83,36 @@ def buscar_videos_pexels(query, api_key, per_page=5):
150
  timeout=20
151
  )
152
  response.raise_for_status()
 
153
  data = response.json()
154
  videos = data.get('videos', [])
155
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
156
  return videos
 
157
  except requests.exceptions.RequestException as e:
158
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
159
  except json.JSONDecodeError:
160
  logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
161
  except Exception as e:
162
  logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
 
163
  return []
 
164
  def generate_script(prompt, max_length=150):
165
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
166
  if not tokenizer or not model:
167
  logger.warning("Modelos GPT-2 no disponibles - Usando prompt original como guion.")
168
  return prompt.strip()
 
169
  instruction_phrase_start = "Escribe un guion corto, interesante y coherente sobre:"
170
  ai_prompt = f"{instruction_phrase_start} {prompt}"
 
171
  try:
172
  inputs = tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=512)
173
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
174
  model.to(device)
175
  inputs = {k: v.to(device) for k, v in inputs.items()}
 
176
  outputs = model.generate(
177
  **inputs,
178
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
@@ -185,154 +125,189 @@ def generate_script(prompt, max_length=150):
185
  eos_token_id=tokenizer.eos_token_id,
186
  no_repeat_ngram_size=3
187
  )
 
188
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
 
189
  cleaned_text = text.strip()
190
  try:
191
- prompt_in_output_idx = text.lower().find(prompt.lower())
192
- if prompt_in_output_idx != -1:
193
- cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
194
- logger.debug("Texto limpiado tomando parte después del prompt original.")
195
  else:
196
- instruction_start_idx = text.find(instruction_phrase_start)
197
- if instruction_start_idx != -1:
198
- cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
199
- logger.debug("Texto limpiado tomando parte después de la frase de instrucción base.")
200
- else:
201
- logger.warning("No se pudo identificar el inicio del guión generado. Usando texto generado completo.")
202
- cleaned_text = text.strip()
 
 
 
203
  except Exception as e:
204
- logger.warning(f"Error durante la limpieza heurística del guión de IA: {e}. Usando texto generado sin limpieza adicional.")
205
- cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
 
206
  if not cleaned_text or len(cleaned_text) < 10:
207
- 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).")
208
- cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
 
209
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
210
  cleaned_text = cleaned_text.lstrip(':').strip()
211
  cleaned_text = cleaned_text.lstrip('.').strip()
 
212
  sentences = cleaned_text.split('.')
213
  if sentences and sentences[0].strip():
214
  final_text = sentences[0].strip() + '.'
215
  if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7:
216
- final_text += " " + sentences[1].strip() + "."
217
- final_text = final_text.replace("..", ".")
 
218
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
219
  return final_text.strip()
 
220
  logger.info(f"Guion generado final (sin oraciones completas detectadas - Truncado): '{cleaned_text[:100]}...'")
221
  return cleaned_text.strip()
 
222
  except Exception as e:
223
  logger.error(f"Error generando guion con GPT-2 (fuera del bloque de limpieza): {str(e)}", exc_info=True)
224
  logger.warning("Usando prompt original como guion debido al error de generación.")
225
  return prompt.strip()
 
 
226
  async def text_to_speech(text, output_path, voice):
227
  logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice} | Salida: {output_path}")
228
  if not text or not text.strip():
229
  logger.warning("Texto vacío para TTS")
230
  return False
 
231
  try:
232
  communicate = edge_tts.Communicate(text, voice)
233
  await communicate.save(output_path)
 
234
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
235
  logger.info(f"Audio guardado exitosamente en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
236
  return True
237
  else:
238
  logger.error(f"TTS guardó un archivo pequeño o vacío en: {output_path}")
239
  return False
 
240
  except Exception as e:
241
  logger.error(f"Error en TTS con voz '{voice}': {str(e)}", exc_info=True)
242
  return False
 
243
  def download_video_file(url, temp_dir):
244
  if not url:
245
  logger.warning("URL de video no proporcionada para descargar")
246
  return None
 
247
  try:
248
  logger.info(f"Descargando video desde: {url[:80]}...")
249
  os.makedirs(temp_dir, exist_ok=True)
250
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
251
  output_path = os.path.join(temp_dir, file_name)
 
252
  with requests.get(url, stream=True, timeout=60) as r:
253
  r.raise_for_status()
254
  with open(output_path, 'wb') as f:
255
  for chunk in r.iter_content(chunk_size=8192):
256
  f.write(chunk)
 
257
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
258
- logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
259
- return output_path
260
  else:
261
- 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")
262
- if os.path.exists(output_path):
263
- os.remove(output_path)
264
- return None
 
265
  except requests.exceptions.RequestException as e:
266
  logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
267
  except Exception as e:
268
  logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
 
269
  return None
 
270
  def loop_audio_to_length(audio_clip, target_duration):
271
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
 
272
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
273
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
274
  try:
275
  sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
276
- return AudioClip(lambda t: 0, duration=target_duration, fps=sr)
277
  except Exception as e:
278
- logger.error(f"Could not create silence clip: {e}", exc_info=True)
279
- return AudioFileClip(filename="")
 
280
  if audio_clip.duration >= target_duration:
281
  logger.debug("Audio clip already longer or equal to target. Trimming.")
282
  trimmed_clip = audio_clip.subclip(0, target_duration)
283
  if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
284
- logger.error("Trimmed audio clip is invalid.")
285
- try:
286
- trimmed_clip.close()
287
- except:
288
- pass
289
- return AudioFileClip(filename="")
290
  return trimmed_clip
 
291
  loops = math.ceil(target_duration / audio_clip.duration)
292
  logger.debug(f"Creando {loops} loops de audio")
 
293
  audio_segments = [audio_clip] * loops
294
  looped_audio = None
295
  final_looped_audio = None
296
  try:
297
- looped_audio = concatenate_audioclips(audio_segments)
298
- if looped_audio.duration is None or looped_audio.duration <= 0:
 
299
  logger.error("Concatenated audio clip is invalid (None or zero duration).")
300
  raise ValueError("Invalid concatenated audio.")
301
- final_looped_audio = looped_audio.subclip(0, target_duration)
302
- if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
 
 
303
  logger.error("Final subclipped audio clip is invalid (None or zero duration).")
304
  raise ValueError("Invalid final subclipped audio.")
305
- return final_looped_audio
 
 
306
  except Exception as e:
307
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
308
  try:
309
- if audio_clip.duration is not None and audio_clip.duration > 0:
310
- logger.warning("Returning original audio clip (may be too short).")
311
- return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
312
  except:
313
- pass
314
  logger.error("Fallback to original audio clip failed.")
315
  return AudioFileClip(filename="")
 
316
  finally:
317
  if looped_audio is not None and looped_audio is not final_looped_audio:
318
- try:
319
- looped_audio.close()
320
- except:
321
- pass
322
  def extract_visual_keywords_from_script(script_text):
323
  logger.info("Extrayendo palabras clave del guion")
324
  if not script_text or not script_text.strip():
325
  logger.warning("Guion vacío, no se pueden extraer palabras clave.")
326
  return ["naturaleza", "ciudad", "paisaje"]
 
327
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
328
  keywords_list = []
 
329
  if kw_model:
330
  try:
331
  logger.debug("Intentando extracción con KeyBERT...")
332
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
333
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
 
334
  all_keywords = keywords1 + keywords2
335
  all_keywords.sort(key=lambda item: item[1], reverse=True)
 
336
  seen_keywords = set()
337
  for keyword, score in all_keywords:
338
  formatted_keyword = keyword.lower().replace(" ", "+")
@@ -341,37 +316,44 @@ def extract_visual_keywords_from_script(script_text):
341
  seen_keywords.add(formatted_keyword)
342
  if len(keywords_list) >= 5:
343
  break
 
344
  if keywords_list:
345
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
346
  return keywords_list
 
347
  except Exception as e:
348
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
 
349
  logger.debug("Extrayendo palabras clave con método simple...")
350
  words = clean_text.lower().split()
351
  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",
352
  "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
353
  "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á"}
 
354
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
 
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 ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
 
358
  word_counts = Counter(valid_words)
359
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
 
360
  if not top_keywords:
361
- logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
362
- return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
 
363
  logger.info(f"Palabras clave finales: {top_keywords}")
364
  return top_keywords
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
  start_time = datetime.now()
371
  temp_dir_intermediate = None
372
- output_filename = None
373
- permanent_path = None
374
- temp_intermediate_files = []
375
  audio_tts_original = None
376
  musica_audio_original = None
377
  audio_tts = None
@@ -380,67 +362,75 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
380
  video_final = None
381
  source_clips = []
382
  clips_to_concatenate = []
 
383
  try:
 
384
  if prompt_type == "Generar Guion con IA":
385
  guion = generate_script(input_text)
386
  else:
387
  guion = input_text.strip()
 
388
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
 
389
  if not guion.strip():
390
  logger.error("El guion resultante está vacío o solo contiene espacios.")
391
  raise ValueError("El guion está vacío.")
 
392
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
393
  logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
 
 
 
394
  logger.info("Generando audio de voz...")
395
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
396
- tts_voices_to_try = [selected_voice]
397
- fallback_juan = "es-ES-JuanNeural"
398
- fallback_elvira = "es-ES-ElviraNeural"
399
- if fallback_juan and fallback_juan != selected_voice and fallback_juan not in tts_voices_to_try:
400
- tts_voices_to_try.append(fallback_juan)
401
- if fallback_elvira and fallback_elvira != selected_voice and fallback_elvira not in tts_voices_to_try:
402
- tts_voices_to_try.append(fallback_elvira)
403
  tts_success = False
404
- tried_voices = set()
405
- for current_voice in tts_voices_to_try:
406
- if not current_voice or current_voice in tried_voices:
407
- continue
408
- tried_voices.add(current_voice)
409
- logger.info(f"Intentando TTS con voz: {current_voice}...")
410
  try:
411
  tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=current_voice))
412
  if tts_success:
413
- logger.info(f"TTS exitoso con voz '{current_voice}'.")
414
  break
415
  except Exception as e:
416
- logger.warning(f"Fallo al generar TTS con voz '{current_voice}': {str(e)}", exc_info=True)
417
- pass
 
 
 
 
 
 
418
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
419
- 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.")
420
- raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
 
421
  temp_intermediate_files.append(voz_path)
 
422
  audio_tts_original = AudioFileClip(voz_path)
 
423
  if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
424
  logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0) *después* de crear AudioFileClip.")
425
- try:
426
- audio_tts_original.close()
427
- except:
428
- pass
429
  audio_tts_original = None
430
- if os.path.exists(voz_path):
431
- try:
432
- os.remove(voz_path)
433
- except:
434
- pass
435
- if voz_path in temp_intermediate_files:
436
- temp_intermediate_files.remove(voz_path)
437
  raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
 
438
  audio_tts = audio_tts_original
439
  audio_duration = audio_tts_original.duration
440
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
 
441
  if audio_duration < 1.0:
442
- logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
443
- raise ValueError("Generated voice audio is too short (min 1 second required).")
 
 
444
  logger.info("Extrayendo palabras clave...")
445
  try:
446
  keywords = extract_visual_keywords_from_script(guion)
@@ -448,15 +438,18 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
448
  except Exception as e:
449
  logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
450
  keywords = ["naturaleza", "paisaje"]
 
451
  if not keywords:
452
- keywords = ["video", "background"]
 
 
453
  logger.info("Buscando videos en Pexels...")
454
  videos_data = []
455
  total_desired_videos = 10
456
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
 
457
  for keyword in keywords:
458
- if len(videos_data) >= total_desired_videos:
459
- break
460
  try:
461
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
462
  if videos:
@@ -464,34 +457,38 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
464
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
465
  except Exception as e:
466
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
 
467
  if len(videos_data) < total_desired_videos / 2:
468
  logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
469
- generic_keywords = ["mystery", "alien", "ufo", "conspiracy", "paranormal", "supernatural", "horror", "fear", "suspense", "secret", "government", "cover_up", "simulation", "matrix", "apocalypse", "dystopian", "shadow", "occult", "unexplained", "creepy", "extraterrestrial", "abduction", "experiment", "secret_society", "illuminati", "new_world_order", "ancient_aliens", "ufo_sighting", "cryptid", "bigfoot", "loch_ness", "ghost", "haunting", "spirit", "demon", "possession", "exorcism", "witchcraft", "ritual", "cursed", "urban_legend", "myth", "legend", "folklore", "scary", "terror", "panic", "anxiety", "dread", "nightmare", "dark", "gloomy", "fog", "haunted", "cemetery", "asylum", "abandoned", "ruins", "underground", "tunnel", "bunker", "lab", "experiment", "government_secret", "mind_control", "brainwash", "propaganda", "surveillance", "spy", "whistleblower", "leak", "anonymous", "hack", "cyber", "virtual_reality", "ai", "artificial_intelligence", "robot", "cyborg", "apocalyptic", "post_apocalyptic", "zombie", "outbreak", "pandemic", "contagion", "biohazard", "radiation", "nuclear", "doomsday", "armageddon", "revelation", "prophecy", "symbolism", "hidden_meaning", "enigma", "puzzle", "code", "cipher", "mysterious", "unidentified", "anomaly", "glitch", "time_travel", "parallel_universe", "dimension", "portal"]
470
  for keyword in generic_keywords:
471
- if len(videos_data) >= total_desired_videos:
472
- break
473
- try:
474
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
475
  if videos:
476
  videos_data.extend(videos)
477
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
478
- except Exception as e:
479
  logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
 
480
  if not videos_data:
481
  logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
482
  raise ValueError("No se encontraron videos adecuados en Pexels.")
 
483
  video_paths = []
484
  logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
485
  for video in videos_data:
486
  if 'video_files' not in video or not video['video_files']:
487
  logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
488
  continue
 
489
  try:
490
  best_quality = None
491
  for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
492
  if 'link' in vf:
493
  best_quality = vf
494
  break
 
495
  if best_quality and 'link' in best_quality:
496
  path = download_video_file(best_quality['link'], temp_dir_intermediate)
497
  if path:
@@ -499,156 +496,187 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
499
  temp_intermediate_files.append(path)
500
  logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
501
  else:
502
- logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
503
  else:
504
  logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
 
505
  except Exception as e:
506
  logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
 
507
  logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
508
  if not video_paths:
509
  logger.error("No se pudo descargar ningún archivo de video utilizable.")
510
  raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
 
 
511
  logger.info("Procesando y concatenando videos descargados...")
512
  current_duration = 0
513
  min_clip_duration = 0.5
514
  max_clip_segment = 10.0
 
515
  for i, path in enumerate(video_paths):
516
  if current_duration >= audio_duration + max_clip_segment:
517
  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.")
518
  break
 
519
  clip = None
520
  try:
521
  logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
522
  clip = VideoFileClip(path)
523
  source_clips.append(clip)
 
524
  if clip.reader is None or clip.duration is None or clip.duration <= 0:
525
  logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido (reader is None o duración <= 0). Saltando.")
526
  continue
 
527
  remaining_needed = audio_duration - current_duration
528
  potential_use_duration = min(clip.duration, max_clip_segment)
 
529
  if remaining_needed > 0:
530
- segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
531
- segment_duration = max(min_clip_duration, segment_duration)
532
- segment_duration = min(segment_duration, clip.duration)
533
- if segment_duration >= min_clip_duration:
534
- try:
535
- sub = clip.subclip(0, segment_duration)
536
- if sub.reader is None or sub.duration is None or sub.duration <= 0:
537
- logger.warning(f"[{i+1}/{len(video_paths)}] Subclip generado de {path} es inválido. Saltando.")
538
- try:
539
- sub.close()
540
- except:
541
- pass
542
- continue
543
- clips_to_concatenate.append(sub)
544
- current_duration += sub.duration
545
- logger.debug(f"[{i+1}/{len(video_paths)}] Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
546
- except Exception as sub_e:
547
- logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip de {path} ({segment_duration:.1f}s): {str(sub_e)}")
548
- continue
549
- else:
550
- 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.")
 
551
  else:
552
  logger.debug(f"[{i+1}/{len(video_paths)}] Duración de video base ya alcanzada. Saltando clip.")
 
553
  except Exception as e:
554
  logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
555
  continue
 
556
  logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
 
557
  if not clips_to_concatenate:
558
  logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
559
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
 
560
  logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
561
  concatenated_base = None
562
  try:
563
  concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
564
  logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
 
565
  if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
566
- logger.critical("Video base concatenado es inválido después de la primera concatenación (None o duración cero).")
567
- raise ValueError("Fallo al crear video base válido a partir de segmentos.")
 
568
  except Exception as e:
569
- logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
570
- raise ValueError("Fallo durante la concatenación de video inicial.")
571
  finally:
572
- for clip_segment in clips_to_concatenate:
573
- try:
574
- clip_segment.close()
575
- except:
576
- pass
577
- clips_to_concatenate = []
578
  video_base = concatenated_base
 
579
  final_video_base = video_base
 
580
  if final_video_base.duration < audio_duration:
581
  logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
 
582
  num_full_repeats = int(audio_duration // final_video_base.duration)
583
  remaining_duration = audio_duration % final_video_base.duration
 
584
  repeated_clips_list = [final_video_base] * num_full_repeats
585
  if remaining_duration > 0:
586
  try:
587
  remaining_clip = final_video_base.subclip(0, remaining_duration)
588
  if remaining_clip is None or remaining_clip.duration is None or remaining_clip.duration <= 0:
589
  logger.warning(f"Subclip generado para duración restante {remaining_duration:.2f}s es inválido. Saltando.")
590
- try:
591
- remaining_clip.close()
592
- except:
593
- pass
594
  else:
595
- repeated_clips_list.append(remaining_clip)
596
- logger.debug(f"Añadiendo segmento restante: {remaining_duration:.2f}s")
 
597
  except Exception as e:
598
- logger.warning(f"Error creando subclip para duración restante {remaining_duration:.2f}s: {str(e)}")
 
599
  if repeated_clips_list:
600
- logger.info(f"Concatenando {len(repeated_clips_list)} partes para repetición.")
601
- video_base_repeated = None
602
- try:
603
  video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
604
  logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
 
605
  if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
606
- logger.critical("Video base repetido concatenado es inválido.")
607
- raise ValueError("Fallo al crear video base repetido válido.")
 
608
  if final_video_base is not video_base_repeated:
609
- try:
610
- final_video_base.close()
611
- except:
612
- pass
613
  final_video_base = video_base_repeated
614
- except Exception as e:
 
615
  logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
616
  raise ValueError("Fallo durante la repetición de video.")
617
- finally:
618
- if 'repeated_clips_list' in locals():
619
- for clip in repeated_clips_list:
620
- if clip is not final_video_base:
621
- try:
622
- clip.close()
623
- except:
624
- pass
625
  if final_video_base.duration > audio_duration:
626
- 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).")
627
- trimmed_video_base = None
628
- try:
629
  trimmed_video_base = final_video_base.subclip(0, audio_duration)
630
  if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
631
- logger.critical("Video base recortado es inválido.")
632
- raise ValueError("Fallo al crear video base recortado válido.")
 
633
  if final_video_base is not trimmed_video_base:
634
- try:
635
- final_video_base.close()
636
- except:
637
- pass
638
  final_video_base = trimmed_video_base
639
- except Exception as e:
 
640
  logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
641
  raise ValueError("Fallo durante el recorte de video.")
 
 
642
  if final_video_base is None or final_video_base.duration is None or final_video_base.duration <= 0:
643
- logger.critical("Video base final es inválido antes de audio/escritura (None o duración cero).")
644
- raise ValueError("Video base final es inválido.")
 
645
  if final_video_base.size is None or final_video_base.size[0] <= 0 or final_video_base.size[1] <= 0:
646
- logger.critical(f"Video base final tiene tamaño inválido: {final_video_base.size}. No se puede escribir video.")
647
- raise ValueError("Video base final tiene tamaño inválido antes de escribir.")
 
648
  video_base = final_video_base
 
 
649
  logger.info("Procesando audio...")
 
650
  final_audio = audio_tts_original
 
651
  musica_audio_looped = None
 
652
  if musica_file:
653
  musica_audio_original = None
654
  try:
@@ -656,195 +684,204 @@ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
656
  shutil.copyfile(musica_file, music_path)
657
  temp_intermediate_files.append(music_path)
658
  logger.info(f"Música de fondo copiada a: {music_path}")
 
659
  musica_audio_original = AudioFileClip(music_path)
 
660
  if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
661
- logger.warning("Clip de música de fondo parece inválido o tiene duración cero. Saltando música.")
662
- try:
663
- musica_audio_original.close()
664
- except:
665
- pass
666
- musica_audio_original = None
667
  else:
668
- musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
669
- logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
670
- if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
671
- logger.warning("Clip de música de fondo loopeado es inválido. Saltando música.")
672
- try:
673
- musica_audio_looped.close()
674
- except:
675
- pass
676
- musica_audio_looped = None
 
677
  if musica_audio_looped:
678
  composite_audio = CompositeAudioClip([
679
- musica_audio_looped.volumex(0.2),
680
- audio_tts_original.volumex(1.0)
681
  ])
 
682
  if composite_audio.duration is None or composite_audio.duration <= 0:
683
  logger.warning("Clip de audio compuesto es inválido (None o duración cero). Usando solo audio de voz.")
684
- try:
685
- composite_audio.close()
686
- except:
687
- pass
688
  final_audio = audio_tts_original
689
  else:
690
- logger.info("Mezcla de audio completada (voz + música).")
691
- final_audio = composite_audio
692
- musica_audio = musica_audio_looped
 
693
  except Exception as e:
694
  logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
695
  final_audio = audio_tts_original
696
  musica_audio = None
697
  logger.warning("Usando solo audio de voz debido a un error con la música.")
 
 
698
  if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
699
  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.")
700
  try:
701
- if final_audio.duration > video_base.duration:
702
- trimmed_final_audio = final_audio.subclip(0, video_base.duration)
703
- if trimmed_final_audio is None or trimmed_final_audio.duration <= 0:
704
- logger.warning("Audio final recortado es inválido. Usando audio final original.")
705
- try:
706
- trimmed_final_audio.close()
707
- except:
708
- pass
709
- else:
710
- if final_audio is not trimmed_final_audio:
711
- try:
712
- final_audio.close()
713
- except:
714
- pass
715
- final_audio = trimmed_final_audio
716
- logger.warning("Audio final recortado para que coincida con la duración del video.")
717
  except Exception as e:
718
- logger.warning(f"Error ajustando duración del audio final: {str(e)}")
 
 
 
719
  video_final = video_base.set_audio(final_audio)
720
- output_filename = f"video_{int(time.time())}.mp4"
 
 
 
 
 
721
  output_path = os.path.join(temp_dir_intermediate, output_filename)
722
- permanent_path = f"/tmp/{output_filename}"
 
723
  video_final.write_videofile(
724
  output_path,
725
  fps=24,
726
- threads=2,
727
  codec="libx264",
728
  audio_codec="aac",
729
  preset="medium",
730
- ffmpeg_params=[
731
- '-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black',
732
- '-crf', '23'
733
- ],
734
  logger='bar'
735
  )
736
- try:
737
- shutil.copy(output_path, permanent_path)
738
- logger.info(f"Video guardado permanentemente en: {permanent_path}")
739
- except Exception as move_error:
740
- logger.error(f"Error moviendo archivo: {str(move_error)}. Usando path original.")
741
- permanent_path = output_path
742
- try:
743
- video_final.close()
744
- if 'video_base' in locals() and video_base is not None and video_base is not video_final:
745
- video_base.close()
746
- except Exception as close_error:
747
- logger.error(f"Error cerrando clips: {str(close_error)}")
748
  total_time = (datetime.now() - start_time).total_seconds()
749
- logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {permanent_path} | Tiempo total: {total_time:.2f}s")
750
- return permanent_path
 
 
751
  except ValueError as ve:
752
- logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
753
- raise ve
754
  except Exception as e:
755
  logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
756
  raise e
757
  finally:
758
  logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
 
759
  for clip in source_clips:
760
  try:
761
  clip.close()
762
  except Exception as e:
763
  logger.warning(f"Error cerrando clip de video fuente en finally: {str(e)}")
 
764
  for clip_segment in clips_to_concatenate:
765
- try:
766
- clip_segment.close()
767
- except Exception as e:
768
- logger.warning(f"Error cerrando segmento de video en finally: {str(e)}")
 
769
  if musica_audio is not None:
770
  try:
771
  musica_audio.close()
772
  except Exception as e:
773
  logger.warning(f"Error cerrando musica_audio (procesada) en finally: {str(e)}")
 
774
  if musica_audio_original is not None and musica_audio_original is not musica_audio:
775
- try:
776
- musica_audio_original.close()
777
- except Exception as e:
778
- logger.warning(f"Error cerrando musica_audio_original en finally: {str(e)}")
 
779
  if audio_tts is not None and audio_tts is not audio_tts_original:
780
- try:
781
- audio_tts.close()
782
- except Exception as e:
783
- logger.warning(f"Error cerrando audio_tts (procesada) en finally: {str(e)}")
 
784
  if audio_tts_original is not None:
785
- try:
786
- audio_tts_original.close()
787
- except Exception as e:
788
- logger.warning(f"Error cerrando audio_tts_original en finalmente: {str(e)}")
 
789
  if video_final is not None:
790
  try:
791
  video_final.close()
792
  except Exception as e:
793
  logger.warning(f"Error cerrando video_final en finally: {str(e)}")
794
  elif video_base is not None and video_base is not video_final:
795
- try:
796
- video_base.close()
797
- except Exception as e:
798
- logger.warning(f"Error cerrando video_base en finally: {str(e)}")
 
799
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
800
- final_output_in_temp = None
801
- if output_filename:
802
- final_output_in_temp = os.path.join(temp_dir_intermediate, output_filename)
803
- for path in temp_intermediate_files:
804
- try:
805
- if os.path.isfile(path) and path != final_output_in_temp and path != permanent_path:
806
- logger.debug(f"Eliminando archivo temporal intermedio: {path}")
807
- os.remove(path)
808
- elif os.path.isfile(path) and (path == final_output_in_temp or path == permanent_path):
809
- logger.debug(f"Saltando eliminación del archivo de video final: {path}")
810
- except Exception as e:
811
- logger.warning(f"No se pudo eliminar archivo temporal intermedio {path}: {str(e)}")
812
- logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
813
- def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
 
 
 
814
  logger.info("="*80)
815
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
 
 
816
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
 
817
  output_video = None
818
  output_file = None
819
  status_msg = gr.update(value="⏳ Procesando...", interactive=False)
 
820
  if not input_text or not input_text.strip():
821
  logger.warning("Texto de entrada vacío.")
 
822
  return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
823
- voice_ids_disponibles = [v[1] for v in AVAILABLE_VOICES]
824
- if selected_voice not in voice_ids_disponibles:
825
- logger.warning(f"Voz seleccionada inválida o no encontrada en la lista: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}.")
826
- selected_voice = DEFAULT_VOICE_ID
827
- else:
828
- logger.info(f"Voz seleccionada validada: {selected_voice}")
829
  logger.info(f"Tipo de entrada: {prompt_type}")
830
  logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
831
  if musica_file:
832
  logger.info(f"Archivo de música recibido: {musica_file}")
833
  else:
834
  logger.info("No se proporcionó archivo de música.")
835
- logger.info(f"Voz final a usar (ID): {selected_voice}")
836
  try:
837
  logger.info("Llamando a crear_video...")
838
- video_path = crear_video(prompt_type, input_text, selected_voice, musica_file)
 
 
839
  if video_path and os.path.exists(video_path):
840
  logger.info(f"crear_video retornó path: {video_path}")
841
  logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
842
- output_video = video_path
843
- output_file = video_path
844
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
845
  else:
846
  logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
847
  status_msg = gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
 
848
  except ValueError as ve:
849
  logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
850
  status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
@@ -853,13 +890,19 @@ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
853
  status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
854
  finally:
855
  logger.info("Fin del handler run_app.")
 
856
  return output_video, output_file, status_msg
 
 
 
857
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
858
  .gradio-container {max-width: 800px; margin: auto;}
859
  h1 {text-align: center;}
860
  """) as app:
 
861
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
862
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
 
863
  with gr.Row():
864
  with gr.Column():
865
  prompt_type = gr.Radio(
@@ -867,6 +910,9 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
867
  label="Método de Entrada",
868
  value="Generar Guion con IA"
869
  )
 
 
 
870
  with gr.Column(visible=True) as ia_guion_column:
871
  prompt_ia = gr.Textbox(
872
  label="Tema para IA",
@@ -875,6 +921,7 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
875
  max_lines=4,
876
  value=""
877
  )
 
878
  with gr.Column(visible=False) as manual_guion_column:
879
  prompt_manual = gr.Textbox(
880
  label="Tu Guion Completo",
@@ -883,19 +930,16 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
883
  max_lines=10,
884
  value=""
885
  )
 
886
  musica_input = gr.Audio(
887
  label="Música de fondo (opcional)",
888
  type="filepath",
889
  interactive=True,
890
  value=None
891
  )
892
- voice_dropdown = gr.Dropdown(
893
- label="Seleccionar Voz para Guion",
894
- choices=AVAILABLE_VOICES,
895
- value=DEFAULT_VOICE_ID,
896
- interactive=True
897
- )
898
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
 
899
  with gr.Column():
900
  video_output = gr.Video(
901
  label="Previsualización del Video Generado",
@@ -905,7 +949,7 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
905
  file_output = gr.File(
906
  label="Descargar Archivo de Video",
907
  interactive=False,
908
- visible=False
909
  )
910
  status_output = gr.Textbox(
911
  label="Estado",
@@ -914,38 +958,58 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
914
  placeholder="Esperando acción...",
915
  value="Esperando entrada..."
916
  )
 
 
 
917
  prompt_type.change(
918
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
919
  gr.update(visible=x == "Usar Mi Guion")),
920
  inputs=prompt_type,
 
921
  outputs=[ia_guion_column, manual_guion_column]
922
  )
 
 
923
  generate_btn.click(
 
 
924
  lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
925
  outputs=[video_output, file_output, status_output],
926
- queue=True,
927
  ).then(
 
928
  run_app,
929
- inputs=[prompt_type, prompt_ia, prompt_manual, musica_input, voice_dropdown],
 
 
930
  outputs=[video_output, file_output, status_output]
931
  ).then(
 
 
 
932
  lambda video_path, file_path, status_msg: gr.update(visible=file_path is not None),
 
933
  inputs=[video_output, file_output, status_output],
 
934
  outputs=[file_output]
935
  )
 
 
936
  gr.Markdown("### Instrucciones:")
937
  gr.Markdown("""
938
  1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
939
- 2. **Selecciona el tipo de entrada**: "Generar Guion con IA" o "Usar Mi Guion".
940
- 3. **Sube música** (opcional): Selecciona un archio de audio (MP3, WAV, etc.).
941
- 4. **Selecciona la voz** deseada del desplegable.
942
- 5. **Haz clic en "✨ Generar Video"**.
943
- 6. Espera a que se procese el video. Verás el estado.
944
- 7. La previsualización aparecerá si es posible, y siempre un enlace **Descargar Archivo de Video** se mostrará si la generación fue exitosa.
945
- 8. Revisa `video_generator_full.log` para detalles si hay errores.
 
946
  """)
947
  gr.Markdown("---")
948
  gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
 
949
  if __name__ == "__main__":
950
  logger.info("Verificando dependencias críticas...")
951
  try:
@@ -953,12 +1017,13 @@ if __name__ == "__main__":
953
  try:
954
  temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
955
  temp_clip.close()
956
- logger.info("Clips base de MoviePy creados y cerrados exitosamente. FFmpeg parece accesible.")
957
  except Exception as e:
958
  logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
 
959
  except Exception as e:
960
- logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
961
- os.environ['GRADIO_SERVER_TIMEOUT'] = '1200'
962
  logger.info("Iniciando aplicación Gradio...")
963
  try:
964
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
1
+ import os
2
  import asyncio
3
  import logging
4
  import tempfile
 
9
  import torch
10
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
11
  from keybert import KeyBERT
12
+ # Importación correcta: Solo 'concatenate_videoclips'
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(
22
  level=logging.DEBUG,
23
  format='%(asctime)s - %(levelname)s - %(message)s',
 
30
  logger.info("="*80)
31
  logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
32
  logger.info("="*80)
33
+
34
+ # Clave API de Pexels
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
36
  if not PEXELS_API_KEY:
37
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
38
+ # raise ValueError("API key de Pexels no configurada")
39
+
40
+ # Inicialización de modelos
41
  MODEL_NAME = "datificate/gpt2-small-spanish"
42
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
43
  tokenizer = None
 
51
  except Exception as e:
52
  logger.error(f"FALLA CRÍTICA al cargar GPT-2: {str(e)}", exc_info=True)
53
  tokenizer = model = None
54
+
55
  logger.info("Cargando modelo KeyBERT...")
56
  kw_model = None
57
  try:
 
60
  except Exception as e:
61
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
62
  kw_model = None
63
+
64
  def buscar_videos_pexels(query, api_key, per_page=5):
65
  if not api_key:
66
  logger.warning("No se puede buscar en Pexels: API Key no configurada.")
67
  return []
68
+
69
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
70
  headers = {"Authorization": api_key}
71
  try:
 
75
  "orientation": "landscape",
76
  "size": "medium"
77
  }
78
+
79
  response = requests.get(
80
  "https://api.pexels.com/videos/search",
81
  headers=headers,
 
83
  timeout=20
84
  )
85
  response.raise_for_status()
86
+
87
  data = response.json()
88
  videos = data.get('videos', [])
89
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
90
  return videos
91
+
92
  except requests.exceptions.RequestException as e:
93
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
94
  except json.JSONDecodeError:
95
  logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
96
  except Exception as e:
97
  logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
98
+
99
  return []
100
+
101
  def generate_script(prompt, max_length=150):
102
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
103
  if not tokenizer or not model:
104
  logger.warning("Modelos GPT-2 no disponibles - Usando prompt original como guion.")
105
  return prompt.strip()
106
+
107
  instruction_phrase_start = "Escribe un guion corto, interesante y coherente sobre:"
108
  ai_prompt = f"{instruction_phrase_start} {prompt}"
109
+
110
  try:
111
  inputs = tokenizer(ai_prompt, return_tensors="pt", truncation=True, max_length=512)
112
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
113
  model.to(device)
114
  inputs = {k: v.to(device) for k, v in inputs.items()}
115
+
116
  outputs = model.generate(
117
  **inputs,
118
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
 
125
  eos_token_id=tokenizer.eos_token_id,
126
  no_repeat_ngram_size=3
127
  )
128
+
129
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
130
+
131
  cleaned_text = text.strip()
132
  try:
133
+ instruction_end_idx = text.find(instruction_phrase)
134
+ if instruction_end_idx != -1:
135
+ cleaned_text = text[instruction_end_idx + len(instruction_phrase):].strip()
136
+ logger.debug("Instrucción inicial encontrada y eliminada del guión generado.")
137
  else:
138
+ instruction_start_idx = text.find(instruction_phrase_start)
139
+ if instruction_start_idx != -1:
140
+ prompt_in_output_idx = text.find(prompt, instruction_start_idx)
141
+ if prompt_in_output_idx != -1:
142
+ cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
143
+ logger.debug("Instrucción base y prompt encontrados y eliminados del guión generado.")
144
+ else:
145
+ cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
146
+ logger.debug("Instrucción base encontrada, eliminada del guión generado (sin prompt detectado).")
147
+
148
  except Exception as e:
149
+ logger.warning(f"Error durante la limpieza heurística del guión de IA: {e}. Usando texto generado sin limpieza adicional.")
150
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
151
+
152
  if not cleaned_text or len(cleaned_text) < 10:
153
+ logger.warning("El guión generado parece muy corto o vacío después de la limpieza. Usando el texto generado original (sin limpieza heurística).")
154
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip()
155
+
156
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
157
  cleaned_text = cleaned_text.lstrip(':').strip()
158
  cleaned_text = cleaned_text.lstrip('.').strip()
159
+
160
  sentences = cleaned_text.split('.')
161
  if sentences and sentences[0].strip():
162
  final_text = sentences[0].strip() + '.'
163
  if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7:
164
+ final_text += " " + sentences[1].strip() + "."
165
+ final_text = final_text.replace("..", ".")
166
+
167
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
168
  return final_text.strip()
169
+
170
  logger.info(f"Guion generado final (sin oraciones completas detectadas - Truncado): '{cleaned_text[:100]}...'")
171
  return cleaned_text.strip()
172
+
173
  except Exception as e:
174
  logger.error(f"Error generando guion con GPT-2 (fuera del bloque de limpieza): {str(e)}", exc_info=True)
175
  logger.warning("Usando prompt original como guion debido al error de generación.")
176
  return prompt.strip()
177
+
178
+ # Función TTS con voz especificada
179
  async def text_to_speech(text, output_path, voice):
180
  logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice} | Salida: {output_path}")
181
  if not text or not text.strip():
182
  logger.warning("Texto vacío para TTS")
183
  return False
184
+
185
  try:
186
  communicate = edge_tts.Communicate(text, voice)
187
  await communicate.save(output_path)
188
+
189
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
190
  logger.info(f"Audio guardado exitosamente en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
191
  return True
192
  else:
193
  logger.error(f"TTS guardó un archivo pequeño o vacío en: {output_path}")
194
  return False
195
+
196
  except Exception as e:
197
  logger.error(f"Error en TTS con voz '{voice}': {str(e)}", exc_info=True)
198
  return False
199
+
200
  def download_video_file(url, temp_dir):
201
  if not url:
202
  logger.warning("URL de video no proporcionada para descargar")
203
  return None
204
+
205
  try:
206
  logger.info(f"Descargando video desde: {url[:80]}...")
207
  os.makedirs(temp_dir, exist_ok=True)
208
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
209
  output_path = os.path.join(temp_dir, file_name)
210
+
211
  with requests.get(url, stream=True, timeout=60) as r:
212
  r.raise_for_status()
213
  with open(output_path, 'wb') as f:
214
  for chunk in r.iter_content(chunk_size=8192):
215
  f.write(chunk)
216
+
217
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
218
+ logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
219
+ return output_path
220
  else:
221
+ 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")
222
+ if os.path.exists(output_path):
223
+ os.remove(output_path)
224
+ return None
225
+
226
  except requests.exceptions.RequestException as e:
227
  logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
228
  except Exception as e:
229
  logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
230
+
231
  return None
232
+
233
  def loop_audio_to_length(audio_clip, target_duration):
234
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
235
+
236
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
237
  logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
238
  try:
239
  sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
240
+ return AudioClip(lambda t: 0, duration=target_duration, sr=sr)
241
  except Exception as e:
242
+ logger.error(f"Could not create silence clip: {e}", exc_info=True)
243
+ return AudioFileClip(filename="")
244
+
245
  if audio_clip.duration >= target_duration:
246
  logger.debug("Audio clip already longer or equal to target. Trimming.")
247
  trimmed_clip = audio_clip.subclip(0, target_duration)
248
  if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
249
+ logger.error("Trimmed audio clip is invalid.")
250
+ try: trimmed_clip.close()
251
+ except: pass
252
+ return AudioFileClip(filename="")
 
 
253
  return trimmed_clip
254
+
255
  loops = math.ceil(target_duration / audio_clip.duration)
256
  logger.debug(f"Creando {loops} loops de audio")
257
+
258
  audio_segments = [audio_clip] * loops
259
  looped_audio = None
260
  final_looped_audio = None
261
  try:
262
+ looped_audio = concatenate_audioclips(audio_segments)
263
+
264
+ if looped_audio.duration is None or looped_audio.duration <= 0:
265
  logger.error("Concatenated audio clip is invalid (None or zero duration).")
266
  raise ValueError("Invalid concatenated audio.")
267
+
268
+ final_looped_audio = looped_audio.subclip(0, target_duration)
269
+
270
+ if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
271
  logger.error("Final subclipped audio clip is invalid (None or zero duration).")
272
  raise ValueError("Invalid final subclipped audio.")
273
+
274
+ return final_looped_audio
275
+
276
  except Exception as e:
277
  logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
278
  try:
279
+ if audio_clip.duration is not None and audio_clip.duration > 0:
280
+ logger.warning("Returning original audio clip (may be too short).")
281
+ return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
282
  except:
283
+ pass
284
  logger.error("Fallback to original audio clip failed.")
285
  return AudioFileClip(filename="")
286
+
287
  finally:
288
  if looped_audio is not None and looped_audio is not final_looped_audio:
289
+ try: looped_audio.close()
290
+ except: pass
291
+
292
+
293
  def extract_visual_keywords_from_script(script_text):
294
  logger.info("Extrayendo palabras clave del guion")
295
  if not script_text or not script_text.strip():
296
  logger.warning("Guion vacío, no se pueden extraer palabras clave.")
297
  return ["naturaleza", "ciudad", "paisaje"]
298
+
299
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
300
  keywords_list = []
301
+
302
  if kw_model:
303
  try:
304
  logger.debug("Intentando extracción con KeyBERT...")
305
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
306
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
307
+
308
  all_keywords = keywords1 + keywords2
309
  all_keywords.sort(key=lambda item: item[1], reverse=True)
310
+
311
  seen_keywords = set()
312
  for keyword, score in all_keywords:
313
  formatted_keyword = keyword.lower().replace(" ", "+")
 
316
  seen_keywords.add(formatted_keyword)
317
  if len(keywords_list) >= 5:
318
  break
319
+
320
  if keywords_list:
321
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
322
  return keywords_list
323
+
324
  except Exception as e:
325
  logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
326
+
327
  logger.debug("Extrayendo palabras clave con método simple...")
328
  words = clean_text.lower().split()
329
  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",
330
  "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
331
  "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á"}
332
+
333
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
334
+
335
  if not valid_words:
336
  logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
337
+ return ["naturaleza", "ciudad", "paisaje"]
338
+
339
  word_counts = Counter(valid_words)
340
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
341
+
342
  if not top_keywords:
343
+ logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
344
+ return ["naturaleza", "ciudad", "paisaje"]
345
+
346
  logger.info(f"Palabras clave finales: {top_keywords}")
347
  return top_keywords
348
+
349
+ def crear_video(prompt_type, input_text, musica_file=None):
350
  logger.info("="*80)
351
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
352
  logger.debug(f"Input: '{input_text[:100]}...'")
353
+
354
  start_time = datetime.now()
355
  temp_dir_intermediate = None
356
+
 
 
357
  audio_tts_original = None
358
  musica_audio_original = None
359
  audio_tts = None
 
362
  video_final = None
363
  source_clips = []
364
  clips_to_concatenate = []
365
+
366
  try:
367
+ # 1. Generar o usar guion
368
  if prompt_type == "Generar Guion con IA":
369
  guion = generate_script(input_text)
370
  else:
371
  guion = input_text.strip()
372
+
373
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
374
+
375
  if not guion.strip():
376
  logger.error("El guion resultante está vacío o solo contiene espacios.")
377
  raise ValueError("El guion está vacío.")
378
+
379
  temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
380
  logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
381
+ temp_intermediate_files = []
382
+
383
+ # 2. Generar audio de voz con reintentos y voz de respaldo
384
  logger.info("Generando audio de voz...")
385
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
386
+
387
+ primary_voice = "es-ES-JuanNeural"
388
+ fallback_voice = "es-ES-ElviraNeural" # Otra voz en español
 
 
 
 
389
  tts_success = False
390
+ retries = 3
391
+
392
+ for attempt in range(retries):
393
+ current_voice = primary_voice if attempt == 0 else fallback_voice
394
+ if attempt > 0: logger.warning(f"Reintentando TTS ({attempt+1}/{retries})...")
395
+ logger.info(f"Intentando TTS con voz: {current_voice}")
396
  try:
397
  tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=current_voice))
398
  if tts_success:
399
+ logger.info(f"TTS exitoso en intento {attempt + 1} con voz {current_voice}.")
400
  break
401
  except Exception as e:
402
+ pass
403
+
404
+ if not tts_success and attempt == 0 and primary_voice != fallback_voice:
405
+ logger.warning(f"Fallo con voz {primary_voice}, intentando voz de respaldo: {fallback_voice}")
406
+ elif not tts_success and attempt < retries - 1:
407
+ logger.warning(f"Fallo con voz {current_voice}, reintentando...")
408
+
409
+
410
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
411
+ logger.error(f"Fallo en la generación de voz después de {retries} intentos. Archivo de audio no creado o es muy pequeño.")
412
+ raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
413
+
414
  temp_intermediate_files.append(voz_path)
415
+
416
  audio_tts_original = AudioFileClip(voz_path)
417
+
418
  if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
419
  logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0) *después* de crear AudioFileClip.")
420
+ try: audio_tts_original.close()
421
+ except: pass
 
 
422
  audio_tts_original = None
 
 
 
 
 
 
 
423
  raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
424
+
425
  audio_tts = audio_tts_original
426
  audio_duration = audio_tts_original.duration
427
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
428
+
429
  if audio_duration < 1.0:
430
+ logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
431
+ raise ValueError("Generated voice audio is too short (min 1 second required).")
432
+
433
+ # 3. Extraer palabras clave
434
  logger.info("Extrayendo palabras clave...")
435
  try:
436
  keywords = extract_visual_keywords_from_script(guion)
 
438
  except Exception as e:
439
  logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
440
  keywords = ["naturaleza", "paisaje"]
441
+
442
  if not keywords:
443
+ keywords = ["video", "background"]
444
+
445
+ # 4. Buscar y descargar videos
446
  logger.info("Buscando videos en Pexels...")
447
  videos_data = []
448
  total_desired_videos = 10
449
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
450
+
451
  for keyword in keywords:
452
+ if len(videos_data) >= total_desired_videos: break
 
453
  try:
454
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
455
  if videos:
 
457
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
458
  except Exception as e:
459
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
460
+
461
  if len(videos_data) < total_desired_videos / 2:
462
  logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
463
+ generic_keywords = ["nature", "city", "background", "abstract"]
464
  for keyword in generic_keywords:
465
+ if len(videos_data) >= total_desired_videos: break
466
+ try:
 
467
  videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
468
  if videos:
469
  videos_data.extend(videos)
470
  logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
471
+ except Exception as e:
472
  logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
473
+
474
  if not videos_data:
475
  logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
476
  raise ValueError("No se encontraron videos adecuados en Pexels.")
477
+
478
  video_paths = []
479
  logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
480
  for video in videos_data:
481
  if 'video_files' not in video or not video['video_files']:
482
  logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
483
  continue
484
+
485
  try:
486
  best_quality = None
487
  for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
488
  if 'link' in vf:
489
  best_quality = vf
490
  break
491
+
492
  if best_quality and 'link' in best_quality:
493
  path = download_video_file(best_quality['link'], temp_dir_intermediate)
494
  if path:
 
496
  temp_intermediate_files.append(path)
497
  logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
498
  else:
499
+ logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
500
  else:
501
  logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
502
+
503
  except Exception as e:
504
  logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
505
+
506
  logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
507
  if not video_paths:
508
  logger.error("No se pudo descargar ningún archivo de video utilizable.")
509
  raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
510
+
511
+ # 5. Procesar y concatenar clips de video
512
  logger.info("Procesando y concatenando videos descargados...")
513
  current_duration = 0
514
  min_clip_duration = 0.5
515
  max_clip_segment = 10.0
516
+
517
  for i, path in enumerate(video_paths):
518
  if current_duration >= audio_duration + max_clip_segment:
519
  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.")
520
  break
521
+
522
  clip = None
523
  try:
524
  logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
525
  clip = VideoFileClip(path)
526
  source_clips.append(clip)
527
+
528
  if clip.reader is None or clip.duration is None or clip.duration <= 0:
529
  logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido (reader is None o duración <= 0). Saltando.")
530
  continue
531
+
532
  remaining_needed = audio_duration - current_duration
533
  potential_use_duration = min(clip.duration, max_clip_segment)
534
+
535
  if remaining_needed > 0:
536
+ segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
537
+ segment_duration = max(min_clip_duration, segment_duration)
538
+ segment_duration = min(segment_duration, clip.duration)
539
+
540
+ if segment_duration >= min_clip_duration:
541
+ try:
542
+ sub = clip.subclip(0, segment_duration)
543
+ if sub.reader is None or sub.duration is None or sub.duration <= 0:
544
+ logger.warning(f"[{i+1}/{len(video_paths)}] Subclip generado de {path} es inválido. Saltando.")
545
+ try: sub.close()
546
+ except: pass
547
+ continue
548
+
549
+ clips_to_concatenate.append(sub)
550
+ current_duration += sub.duration
551
+ logger.debug(f"[{i+1}/{len(video_paths)}] Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
552
+
553
+ except Exception as sub_e:
554
+ logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip de {path} ({segment_duration:.1f}s): {str(sub_e)}")
555
+ continue
556
+ else:
557
+ 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.")
558
  else:
559
  logger.debug(f"[{i+1}/{len(video_paths)}] Duración de video base ya alcanzada. Saltando clip.")
560
+
561
  except Exception as e:
562
  logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
563
  continue
564
+
565
  logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
566
+
567
  if not clips_to_concatenate:
568
  logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
569
  raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
570
+
571
  logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
572
  concatenated_base = None
573
  try:
574
  concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
575
  logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
576
+
577
  if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
578
+ logger.critical("Video base concatenado es inválido después de la primera concatenación (None o duración cero).")
579
+ raise ValueError("Fallo al crear video base válido a partir de segmentos.")
580
+
581
  except Exception as e:
582
+ logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
583
+ raise ValueError("Fallo durante la concatenación de video inicial.")
584
  finally:
585
+ for clip_segment in clips_to_concatenate:
586
+ try: clip_segment.close()
587
+ except: pass
588
+ clips_to_concatenate = []
589
+
 
590
  video_base = concatenated_base
591
+
592
  final_video_base = video_base
593
+
594
  if final_video_base.duration < audio_duration:
595
  logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
596
+
597
  num_full_repeats = int(audio_duration // final_video_base.duration)
598
  remaining_duration = audio_duration % final_video_base.duration
599
+
600
  repeated_clips_list = [final_video_base] * num_full_repeats
601
  if remaining_duration > 0:
602
  try:
603
  remaining_clip = final_video_base.subclip(0, remaining_duration)
604
  if remaining_clip is None or remaining_clip.duration is None or remaining_clip.duration <= 0:
605
  logger.warning(f"Subclip generado para duración restante {remaining_duration:.2f}s es inválido. Saltando.")
606
+ try: remaining_clip.close()
607
+ except: pass
 
 
608
  else:
609
+ repeated_clips_list.append(remaining_clip)
610
+ logger.debug(f"Añadiendo segmento restante: {remaining_duration:.2f}s")
611
+
612
  except Exception as e:
613
+ logger.warning(f"Error creando subclip para duración restante {remaining_duration:.2f}s: {str(e)}")
614
+
615
  if repeated_clips_list:
616
+ logger.info(f"Concatenando {len(repeated_clips_list)} partes para repetición.")
617
+ video_base_repeated = None
618
+ try:
619
  video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
620
  logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
621
+
622
  if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
623
+ logger.critical("Video base repetido concatenado es inválido.")
624
+ raise ValueError("Fallo al crear video base repetido válido.")
625
+
626
  if final_video_base is not video_base_repeated:
627
+ try: final_video_base.close()
628
+ except: pass
629
+
 
630
  final_video_base = video_base_repeated
631
+
632
+ except Exception as e:
633
  logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
634
  raise ValueError("Fallo durante la repetición de video.")
635
+ finally:
636
+ if 'repeated_clips_list' in locals():
637
+ for clip in repeated_clips_list:
638
+ if clip is not final_video_base:
639
+ try: clip.close()
640
+ except: pass
641
+
642
+
643
  if final_video_base.duration > audio_duration:
644
+ 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).")
645
+ trimmed_video_base = None
646
+ try:
647
  trimmed_video_base = final_video_base.subclip(0, audio_duration)
648
  if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
649
+ logger.critical("Video base recortado es inválido.")
650
+ raise ValueError("Fallo al crear video base recortado válido.")
651
+
652
  if final_video_base is not trimmed_video_base:
653
+ try: final_video_base.close()
654
+ except: pass
655
+
 
656
  final_video_base = trimmed_video_base
657
+
658
+ except Exception as e:
659
  logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
660
  raise ValueError("Fallo durante el recorte de video.")
661
+
662
+
663
  if final_video_base is None or final_video_base.duration is None or final_video_base.duration <= 0:
664
+ logger.critical("Video base final es inválido antes de audio/escritura (None o duración cero).")
665
+ raise ValueError("Video base final es inválido.")
666
+
667
  if final_video_base.size is None or final_video_base.size[0] <= 0 or final_video_base.size[1] <= 0:
668
+ logger.critical(f"Video base final tiene tamaño inválido: {final_video_base.size}. No se puede escribir video.")
669
+ raise ValueError("Video base final tiene tamaño inválido antes de escribir.")
670
+
671
  video_base = final_video_base
672
+
673
+ # 6. Manejar música de fondo
674
  logger.info("Procesando audio...")
675
+
676
  final_audio = audio_tts_original
677
+
678
  musica_audio_looped = None
679
+
680
  if musica_file:
681
  musica_audio_original = None
682
  try:
 
684
  shutil.copyfile(musica_file, music_path)
685
  temp_intermediate_files.append(music_path)
686
  logger.info(f"Música de fondo copiada a: {music_path}")
687
+
688
  musica_audio_original = AudioFileClip(music_path)
689
+
690
  if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
691
+ logger.warning("Clip de música de fondo parece inválido o tiene duración cero. Saltando música.")
692
+ try: musica_audio_original.close()
693
+ except: pass
694
+ musica_audio_original = None
 
 
695
  else:
696
+ musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
697
+ logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
698
+
699
+ if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
700
+ logger.warning("Clip de música de fondo loopeado es inválido. Saltando música.")
701
+ try: musica_audio_looped.close()
702
+ except: pass
703
+ musica_audio_looped = None
704
+
705
+
706
  if musica_audio_looped:
707
  composite_audio = CompositeAudioClip([
708
+ musica_audio_looped.volumex(0.2), # Volumen 20% para música
709
+ audio_tts_original.volumex(1.0) # Volumen 100% para voz
710
  ])
711
+
712
  if composite_audio.duration is None or composite_audio.duration <= 0:
713
  logger.warning("Clip de audio compuesto es inválido (None o duración cero). Usando solo audio de voz.")
714
+ try: composite_audio.close()
715
+ except: pass
 
 
716
  final_audio = audio_tts_original
717
  else:
718
+ logger.info("Mezcla de audio completada (voz + música).")
719
+ final_audio = composite_audio
720
+ musica_audio = musica_audio_looped # Asignar para limpieza
721
+
722
  except Exception as e:
723
  logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
724
  final_audio = audio_tts_original
725
  musica_audio = None
726
  logger.warning("Usando solo audio de voz debido a un error con la música.")
727
+
728
+
729
  if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
730
  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.")
731
  try:
732
+ if final_audio.duration > video_base.duration:
733
+ trimmed_final_audio = final_audio.subclip(0, video_base.duration)
734
+ if trimmed_final_audio is None or trimmed_final_audio.duration <= 0:
735
+ logger.warning("Audio final recortado es inválido. Usando audio final original.")
736
+ try: trimmed_final_audio.close()
737
+ except: pass
738
+ else:
739
+ if final_audio is not trimmed_final_audio:
740
+ try: final_audio.close()
741
+ except: pass
742
+ final_audio = trimmed_final_audio
743
+ logger.warning("Audio final recortado para que coincida con la duración del video.")
 
 
 
 
744
  except Exception as e:
745
+ logger.warning(f"Error ajustando duración del audio final: {str(e)}")
746
+
747
+ # 7. Crear video final
748
+ logger.info("Renderizando video final...")
749
  video_final = video_base.set_audio(final_audio)
750
+
751
+ if video_final is None or video_final.duration is None or video_final.duration <= 0:
752
+ logger.critical("Clip de video final (con audio) es inválido antes de escribir (None o duración cero).")
753
+ raise ValueError("Clip de video final es inválido antes de escribir.")
754
+
755
+ output_filename = "final_video.mp4"
756
  output_path = os.path.join(temp_dir_intermediate, output_filename)
757
+ logger.info(f"Escribiendo video final a: {output_path}")
758
+
759
  video_final.write_videofile(
760
  output_path,
761
  fps=24,
762
+ threads=4,
763
  codec="libx264",
764
  audio_codec="aac",
765
  preset="medium",
 
 
 
 
766
  logger='bar'
767
  )
768
+
 
 
 
 
 
 
 
 
 
 
 
769
  total_time = (datetime.now() - start_time).total_seconds()
770
+ logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
771
+
772
+ return output_path
773
+
774
  except ValueError as ve:
775
+ logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
776
+ raise ve
777
  except Exception as e:
778
  logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
779
  raise e
780
  finally:
781
  logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
782
+
783
  for clip in source_clips:
784
  try:
785
  clip.close()
786
  except Exception as e:
787
  logger.warning(f"Error cerrando clip de video fuente en finally: {str(e)}")
788
+
789
  for clip_segment in clips_to_concatenate:
790
+ try:
791
+ clip_segment.close()
792
+ except Exception as e:
793
+ logger.warning(f"Error cerrando segmento de video en finally: {str(e)}")
794
+
795
  if musica_audio is not None:
796
  try:
797
  musica_audio.close()
798
  except Exception as e:
799
  logger.warning(f"Error cerrando musica_audio (procesada) en finally: {str(e)}")
800
+
801
  if musica_audio_original is not None and musica_audio_original is not musica_audio:
802
+ try:
803
+ musica_audio_original.close()
804
+ except Exception as e:
805
+ logger.warning(f"Error cerrando musica_audio_original en finally: {str(e)}")
806
+
807
  if audio_tts is not None and audio_tts is not audio_tts_original:
808
+ try:
809
+ audio_tts.close()
810
+ except Exception as e:
811
+ logger.warning(f"Error cerrando audio_tts (procesada) en finally: {str(e)}")
812
+
813
  if audio_tts_original is not None:
814
+ try:
815
+ audio_tts_original.close()
816
+ except Exception as e:
817
+ logger.warning(f"Error cerrando audio_tts_original en finally: {str(e)}")
818
+
819
  if video_final is not None:
820
  try:
821
  video_final.close()
822
  except Exception as e:
823
  logger.warning(f"Error cerrando video_final en finally: {str(e)}")
824
  elif video_base is not None and video_base is not video_final:
825
+ try:
826
+ video_base.close()
827
+ except Exception as e:
828
+ logger.warning(f"Error cerrando video_base en finally: {str(e)}")
829
+
830
  if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
831
+ final_output_in_temp = os.path.join(temp_dir_intermediate, "final_video.mp4")
832
+
833
+ for path in temp_intermediate_files:
834
+ try:
835
+ if os.path.isfile(path) and path != final_output_in_temp:
836
+ logger.debug(f"Eliminando archivo temporal intermedio: {path}")
837
+ os.remove(path)
838
+ elif os.path.isfile(path) and path == final_output_in_temp:
839
+ logger.debug(f"Saltando eliminación del archivo de video final: {path}")
840
+ except Exception as e:
841
+ logger.warning(f"No se pudo eliminar archivo temporal intermedio {path}: {str(e)}")
842
+
843
+ logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
844
+
845
+
846
+ # La función run_app ahora recibe todos los inputs de texto y el archivo de música
847
+ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file):
848
  logger.info("="*80)
849
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
850
+
851
+ # Elegir el texto de entrada basado en el prompt_type
852
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
853
+
854
  output_video = None
855
  output_file = None
856
  status_msg = gr.update(value="⏳ Procesando...", interactive=False)
857
+
858
  if not input_text or not input_text.strip():
859
  logger.warning("Texto de entrada vacío.")
860
+ # Retornar None para video y archivo, actualizar estado con mensaje de error
861
  return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
862
+
 
 
 
 
 
863
  logger.info(f"Tipo de entrada: {prompt_type}")
864
  logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
865
  if musica_file:
866
  logger.info(f"Archivo de música recibido: {musica_file}")
867
  else:
868
  logger.info("No se proporcionó archivo de música.")
869
+
870
  try:
871
  logger.info("Llamando a crear_video...")
872
+ # Pasar el input_text elegido y el archivo de música a crear_video
873
+ video_path = crear_video(prompt_type, input_text, musica_file)
874
+
875
  if video_path and os.path.exists(video_path):
876
  logger.info(f"crear_video retornó path: {video_path}")
877
  logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
878
+ output_video = video_path # Establecer valor del componente de video
879
+ output_file = video_path # Establecer valor del componente de archivo para descarga
880
  status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
881
  else:
882
  logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
883
  status_msg = gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
884
+
885
  except ValueError as ve:
886
  logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
887
  status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
 
890
  status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
891
  finally:
892
  logger.info("Fin del handler run_app.")
893
+ # Retornar las tres salidas esperadas por el evento click
894
  return output_video, output_file, status_msg
895
+
896
+
897
+ # Interfaz de Gradio
898
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
899
  .gradio-container {max-width: 800px; margin: auto;}
900
  h1 {text-align: center;}
901
  """) as app:
902
+
903
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
904
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
905
+
906
  with gr.Row():
907
  with gr.Column():
908
  prompt_type = gr.Radio(
 
910
  label="Método de Entrada",
911
  value="Generar Guion con IA"
912
  )
913
+
914
+ # Contenedores para los campos de texto para controlar la visibilidad
915
+ # Nombrados para que coincidan con los outputs del evento change
916
  with gr.Column(visible=True) as ia_guion_column:
917
  prompt_ia = gr.Textbox(
918
  label="Tema para IA",
 
921
  max_lines=4,
922
  value=""
923
  )
924
+
925
  with gr.Column(visible=False) as manual_guion_column:
926
  prompt_manual = gr.Textbox(
927
  label="Tu Guion Completo",
 
930
  max_lines=10,
931
  value=""
932
  )
933
+
934
  musica_input = gr.Audio(
935
  label="Música de fondo (opcional)",
936
  type="filepath",
937
  interactive=True,
938
  value=None
939
  )
940
+
 
 
 
 
 
941
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
942
+
943
  with gr.Column():
944
  video_output = gr.Video(
945
  label="Previsualización del Video Generado",
 
949
  file_output = gr.File(
950
  label="Descargar Archivo de Video",
951
  interactive=False,
952
+ visible=False # Ocultar inicialmente
953
  )
954
  status_output = gr.Textbox(
955
  label="Estado",
 
958
  placeholder="Esperando acción...",
959
  value="Esperando entrada..."
960
  )
961
+
962
+ # Evento para mostrar/ocultar los campos de texto según el tipo de prompt
963
+ # Apuntar a los componentes Column padre para controlar la visibilidad
964
  prompt_type.change(
965
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
966
  gr.update(visible=x == "Usar Mi Guion")),
967
  inputs=prompt_type,
968
+ # Pasar los componentes Column
969
  outputs=[ia_guion_column, manual_guion_column]
970
  )
971
+
972
+ # Evento click del botón de generar video
973
  generate_btn.click(
974
+ # Acción 1 (síncrona): Resetear salidas y establecer estado a procesando
975
+ # Retorna None para los 3 outputs iniciales
976
  lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
977
  outputs=[video_output, file_output, status_output],
978
+ queue=True, # Usar la cola de Gradio para tareas largas
979
  ).then(
980
+ # Acción 2 (asíncrona): Llamar a la función principal de procesamiento
981
  run_app,
982
+ # PASAR TODOS LOS INPUTS DE LA INTERFAZ que run_app espera
983
+ inputs=[prompt_type, prompt_ia, prompt_manual, musica_input],
984
+ # run_app retornará los 3 outputs esperados aquí
985
  outputs=[video_output, file_output, status_output]
986
  ).then(
987
+ # Acción 3 (síncrona): Hacer visible el enlace de descarga si se retornó un archivo
988
+ # Esta función recibe las salidas de la Acción 2 (video_path, file_path, status_msg)
989
+ # Solo necesitamos video_path o file_path para decidir si mostrar el enlace
990
  lambda video_path, file_path, status_msg: gr.update(visible=file_path is not None),
991
+ # Inputs son las salidas de la función .then() anterior
992
  inputs=[video_output, file_output, status_output],
993
+ # Actualizamos la visibilidad del componente file_output
994
  outputs=[file_output]
995
  )
996
+
997
+
998
  gr.Markdown("### Instrucciones:")
999
  gr.Markdown("""
1000
  1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
1001
+ 2. **Selecciona el tipo de entrada**:
1002
+ - "Generar Guion con IA": Describe brevemente un tema (ej. "La belleza de las montañas"). La IA generará un guion corto.
1003
+ - "Usar Mi Guion": Escribe el guion completo que quieres para el video.
1004
+ 3. **Sube música** (opcional): Selecciona un archivo de audio (MP3, WAV, etc.) para usar como música de fondo.
1005
+ 4. **Haz clic en "✨ Generar Video"**.
1006
+ 5. Espera a que se procese el video. El tiempo de espera puede variar. Verás el estado en el cuadro de texto.
1007
+ 6. La previsualización del video aparecerá arriba (puede fallar para archivos grandes), y un enlace **Descargar Archivo de Video** se mostrará si la generación fue exitosa.
1008
+ 7. Si hay errores, revisa el log `video_generator_full.log` para más detalles.
1009
  """)
1010
  gr.Markdown("---")
1011
  gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
1012
+
1013
  if __name__ == "__main__":
1014
  logger.info("Verificando dependencias críticas...")
1015
  try:
 
1017
  try:
1018
  temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
1019
  temp_clip.close()
1020
+ logger.info("Clips base de MoviePy (como ColorClip) creados y cerrados exitosamente. FFmpeg parece accesible.")
1021
  except Exception as e:
1022
  logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
1023
+
1024
  except Exception as e:
1025
+ logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
1026
+
1027
  logger.info("Iniciando aplicación Gradio...")
1028
  try:
1029
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)