gnosticdev commited on
Commit
8b182fa
·
verified ·
1 Parent(s): 6797757

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +684 -262
app.py CHANGED
@@ -32,6 +32,7 @@ logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
32
  logger.info("="*80)
33
 
34
  # Diccionario de voces TTS disponibles organizadas por idioma
 
35
  VOCES_DISPONIBLES = {
36
  "Español (España)": {
37
  "es-ES-JuanNeural": "Juan (España) - Masculino",
@@ -99,26 +100,37 @@ def get_voice_choices():
99
  choices = []
100
  for region, voices in VOCES_DISPONIBLES.items():
101
  for voice_id, voice_name in voices.items():
 
102
  choices.append((f"{voice_name} ({region})", voice_id))
103
  return choices
104
 
105
  # Obtener las voces al inicio del script
106
- AVAILABLE_VOICES = get_voice_choices()
107
- DEFAULT_VOICE_ID = "es-MX-DaliaNeural" # Cambiado a una voz más estable
 
 
 
 
 
 
108
  DEFAULT_VOICE_NAME = DEFAULT_VOICE_ID
109
  for text, voice_id in AVAILABLE_VOICES:
110
  if voice_id == DEFAULT_VOICE_ID:
111
  DEFAULT_VOICE_NAME = text
112
  break
 
113
  if DEFAULT_VOICE_ID not in [v[1] for v in AVAILABLE_VOICES]:
114
- DEFAULT_VOICE_ID = AVAILABLE_VOICES[0][1] if AVAILABLE_VOICES else "es-MX-DaliaNeural"
115
- DEFAULT_VOICE_NAME = AVAILABLE_VOICES[0][0] if AVAILABLE_VOICES else "Dalia (México) - Femenino"
 
116
  logger.info(f"Voz por defecto seleccionada (ID): {DEFAULT_VOICE_ID}")
117
 
 
118
  # Clave API de Pexels
119
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
120
  if not PEXELS_API_KEY:
121
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
 
122
 
123
  # Inicialización de modelos
124
  MODEL_NAME = "datificate/gpt2-small-spanish"
@@ -158,6 +170,7 @@ def buscar_videos_pexels(query, api_key, per_page=5):
158
  "orientation": "landscape",
159
  "size": "medium"
160
  }
 
161
  response = requests.get(
162
  "https://api.pexels.com/videos/search",
163
  headers=headers,
@@ -165,19 +178,20 @@ def buscar_videos_pexels(query, api_key, per_page=5):
165
  timeout=20
166
  )
167
  response.raise_for_status()
 
168
  data = response.json()
169
  videos = data.get('videos', [])
170
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
171
  return videos
 
172
  except requests.exceptions.RequestException as e:
173
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
174
- return []
175
  except json.JSONDecodeError:
176
- logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code}")
177
- return []
178
  except Exception as e:
179
- logger.error(f"Error inesperado Pexels para '{query}': {str(e)}")
180
- return []
 
181
 
182
  def generate_script(prompt, max_length=150):
183
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
@@ -193,6 +207,7 @@ def generate_script(prompt, max_length=150):
193
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
194
  model.to(device)
195
  inputs = {k: v.to(device) for k, v in inputs.items()}
 
196
  outputs = model.generate(
197
  **inputs,
198
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
@@ -205,157 +220,247 @@ def generate_script(prompt, max_length=150):
205
  eos_token_id=tokenizer.eos_token_id,
206
  no_repeat_ngram_size=3
207
  )
 
208
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
209
- prompt_in_output_idx = text.lower().find(prompt.lower())
210
- if prompt_in_output_idx != -1:
211
- cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
212
- logger.debug("Texto limpiado tomando parte después del prompt original.")
213
- else:
214
- instruction_start_idx = text.find(instruction_phrase_start)
215
- if instruction_start_idx != -1:
216
- cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
217
- logger.debug("Texto limpiado tomando parte después de la frase de instrucción base.")
 
218
  else:
219
- logger.warning("No se pudo identificar el inicio del guión generado.")
220
- cleaned_text = text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
222
- cleaned_text = cleaned_text.lstrip(':').lstrip('.').strip()
 
 
 
 
223
  sentences = cleaned_text.split('.')
224
  if sentences and sentences[0].strip():
225
  final_text = sentences[0].strip() + '.'
226
- if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7:
227
- final_text += " " + sentences[1].strip() + "."
228
- final_text = final_text.replace("..", ".")
 
 
229
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
230
  return final_text.strip()
231
- logger.info(f"Guion generado final (sin oraciones completas detectadas): '{cleaned_text[:100]}...'")
232
- return cleaned_text.strip()
 
 
233
  except Exception as e:
234
- logger.error(f"Error generando guion con GPT-2: {str(e)}")
 
235
  return prompt.strip()
236
 
 
237
  async def text_to_speech(text, output_path, voice):
238
- logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice}")
239
  if not text or not text.strip():
240
  logger.warning("Texto vacío para TTS")
241
  return False
 
242
  try:
243
  communicate = edge_tts.Communicate(text, voice)
244
  await communicate.save(output_path)
245
- if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
246
- logger.info(f"Audio guardado exitosamente con edge_tts en: {output_path}")
247
- return True
248
- logger.warning(f"edge_tts falló, intentando gTTS...")
249
- except Exception as e:
250
- logger.error(f"Error en edge_tts con voz '{voice}': {str(e)}")
251
 
252
- try:
253
- tts = gTTS(text=text, lang='es')
254
- tts.save(output_path)
255
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
256
- logger.info(f"Audio guardado exitosamente con gTTS en: {output_path}")
257
  return True
258
- logger.error(f"gTTS falló o archivo vacío en: {output_path}")
259
- return False
 
 
260
  except Exception as e:
261
- logger.error(f"Error en gTTS: {str(e)}")
262
  return False
263
 
264
  def download_video_file(url, temp_dir):
265
  if not url:
266
- logger.warning("URL de video no proporcionada")
267
  return None
 
268
  try:
269
  logger.info(f"Descargando video desde: {url[:80]}...")
270
  os.makedirs(temp_dir, exist_ok=True)
271
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
272
  output_path = os.path.join(temp_dir, file_name)
 
273
  with requests.get(url, stream=True, timeout=60) as r:
274
  r.raise_for_status()
275
  with open(output_path, 'wb') as f:
276
  for chunk in r.iter_content(chunk_size=8192):
277
  f.write(chunk)
 
278
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
279
- logger.info(f"Video descargado exitosamente: {output_path}")
280
- return output_path
281
- logger.warning(f"Descarga parece incompleta o vacía: {output_path}")
282
- if os.path.exists(output_path):
283
- os.remove(output_path)
284
- return None
 
 
285
  except requests.exceptions.RequestException as e:
286
- logger.error(f"Error de descarga para {url[:80]}...: {str(e)}")
287
- return None
288
  except Exception as e:
289
- logger.error(f"Error inesperado descargando {url[:80]}...: {str(e)}")
290
- return None
 
291
 
292
  def loop_audio_to_length(audio_clip, target_duration):
293
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
 
294
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
295
- logger.warning("Input audio clip is invalid")
296
- sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
297
- return AudioClip(lambda t: 0, duration=target_duration, fps=sr)
 
 
 
 
 
298
  if audio_clip.duration >= target_duration:
299
- logger.debug("Audio clip ya es suficientemente largo. Recortando.")
300
- return audio_clip.subclip(0, target_duration)
 
 
 
 
 
 
 
301
  loops = math.ceil(target_duration / audio_clip.duration)
302
  logger.debug(f"Creando {loops} loops de audio")
 
 
 
 
303
  try:
304
- looped_audio = concatenate_audioclips([audio_clip] * loops)
305
- final_looped_audio = looped_audio.subclip(0, target_duration)
306
- looped_audio.close()
307
- return final_looped_audio
 
 
 
 
 
 
 
 
 
 
308
  except Exception as e:
309
- logger.error(f"Error concatenando audio: {str(e)}")
310
- return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
  def extract_visual_keywords_from_script(script_text):
313
  logger.info("Extrayendo palabras clave del guion")
314
  if not script_text or not script_text.strip():
315
- logger.warning("Guion vacío")
316
  return ["naturaleza", "ciudad", "paisaje"]
 
317
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
 
 
318
  if kw_model:
319
  try:
 
320
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
321
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
 
322
  all_keywords = keywords1 + keywords2
323
  all_keywords.sort(key=lambda item: item[1], reverse=True)
324
- keywords_list = []
325
  seen_keywords = set()
326
- for keyword, _ in all_keywords:
327
  formatted_keyword = keyword.lower().replace(" ", "+")
328
  if formatted_keyword and formatted_keyword not in seen_keywords:
329
  keywords_list.append(formatted_keyword)
330
  seen_keywords.add(formatted_keyword)
331
  if len(keywords_list) >= 5:
332
  break
 
333
  if keywords_list:
334
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
335
  return keywords_list
 
336
  except Exception as e:
337
- logger.warning(f"KeyBERT falló: {str(e)}. Usando método simple.")
 
338
  logger.debug("Extrayendo palabras clave con método simple...")
339
  words = clean_text.lower().split()
340
- 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"}
 
 
 
341
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
 
342
  if not valid_words:
343
- logger.warning("No se encontraron palabras clave válidas.")
344
- return ["espiritual", "terror", "matrix", "arcontes", "galaxia"]
 
345
  word_counts = Counter(valid_words)
346
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
 
 
 
 
 
347
  logger.info(f"Palabras clave finales: {top_keywords}")
348
  return top_keywords
349
 
350
- async def crear_video_async(prompt_type, input_text, selected_voice, musica_file=None):
 
351
  logger.info("="*80)
352
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
353
  logger.debug(f"Input: '{input_text[:100]}...'")
354
  logger.info(f"Voz seleccionada: {selected_voice}")
 
355
  start_time = datetime.now()
356
- temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
357
- logger.info(f"Directorio temporal creado: {temp_dir_intermediate}")
358
- temp_intermediate_files = []
359
  audio_tts_original = None
360
  musica_audio_original = None
361
  audio_tts = None
@@ -367,169 +472,400 @@ async def crear_video_async(prompt_type, input_text, selected_voice, musica_file
367
 
368
  try:
369
  # 1. Generar o usar guion
370
- guion = generate_script(input_text) if prompt_type == "Generar Guion con IA" else input_text.strip()
 
 
 
 
371
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
 
372
  if not guion.strip():
 
373
  raise ValueError("El guion está vacío.")
374
 
375
- # 2. Generar audio de voz
 
 
 
 
 
376
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
377
- tts_voices_to_try = [selected_voice, "es-MX-DaliaNeural"]
 
 
 
 
 
 
 
 
 
378
  tts_success = False
379
- max_chunk_length = 1000
380
- text_chunks = [guion[i:i + max_chunk_length] for i in range(0, len(guion), max_chunk_length)]
381
- logger.info(f"Texto dividido en {len(text_chunks)} fragmentos para TTS")
382
 
383
  for current_voice in tts_voices_to_try:
384
- logger.info(f"Intentando TTS con voz: {current_voice}")
 
 
 
385
  try:
386
- temp_audio_files = []
387
- for i, chunk in enumerate(text_chunks):
388
- temp_path = os.path.join(temp_dir_intermediate, f"voz_chunk_{i}.mp3")
389
- tts_success = await text_to_speech(chunk, temp_path, current_voice)
390
- if tts_success and os.path.exists(temp_path) and os.path.getsize(temp_path) > 100:
391
- temp_audio_files.append(temp_path)
392
- else:
393
- logger.warning(f"TTS falló para fragmento {i} con voz: {current_voice}")
394
- break
395
- if len(temp_audio_files) == len(text_chunks):
396
- audio_clips = [AudioFileClip(f) for f in temp_audio_files]
397
- concatenated_audio = concatenate_audioclips(audio_clips)
398
- concatenated_audio.write_audiofile(voz_path, codec='mp3')
399
- concatenated_audio.close()
400
- for clip in audio_clips:
401
- clip.close()
402
- tts_success = os.path.exists(voz_path) and os.path.getsize(voz_path) > 100
403
- temp_intermediate_files.extend(temp_audio_files)
404
- if tts_success:
405
- logger.info(f"TTS exitoso con voz: {current_voice}")
406
- break
407
  except Exception as e:
408
- logger.error(f"Error en TTS con voz '{current_voice}': {str(e)}")
 
409
 
410
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
411
- raise ValueError(f"Error generando voz. Intentos con {tts_voices_to_try} y gTTS fallaron.")
 
412
 
413
  temp_intermediate_files.append(voz_path)
 
414
  audio_tts_original = AudioFileClip(voz_path)
415
- if audio_tts_original.duration is None or audio_tts_original.duration <= 0:
416
- raise ValueError("Audio de voz generado es inválido.")
 
 
 
 
 
 
 
 
 
 
 
 
417
  audio_tts = audio_tts_original
418
  audio_duration = audio_tts_original.duration
419
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
420
- if audio_duration < 1.0:
421
- raise ValueError("Audio de voz demasiado corto.")
422
 
 
 
 
423
  # 3. Extraer palabras clave
424
- keywords = extract_visual_keywords_from_script(guion)
 
 
 
 
 
 
 
425
  if not keywords:
426
- keywords = ["video", "background"]
427
- logger.info(f"Palabras clave: {keywords}")
428
 
429
  # 4. Buscar y descargar videos
 
430
  videos_data = []
431
  total_desired_videos = 10
432
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
 
433
  for keyword in keywords:
434
- if len(videos_data) >= total_desired_videos:
435
- break
436
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
437
- videos_data.extend(videos)
 
 
 
 
438
 
439
  if len(videos_data) < total_desired_videos / 2:
440
- generic_keywords = ["mystery", "alien", "ufo", "conspiracy", "paranormal"]
 
441
  for keyword in generic_keywords:
442
- if len(videos_data) >= total_desired_videos:
443
- break
444
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
445
- videos_data.extend(videos)
 
 
 
 
446
 
447
  if not videos_data:
448
- raise ValueError("No se encontraron videos en Pexels.")
 
449
 
450
  video_paths = []
 
451
  for video in videos_data:
452
  if 'video_files' not in video or not video['video_files']:
 
453
  continue
454
- best_quality = max(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), default=None)
455
- if best_quality and 'link' in best_quality:
456
- path = download_video_file(best_quality['link'], temp_dir_intermediate)
457
- if path:
458
- video_paths.append(path)
459
- temp_intermediate_files.append(path)
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  if not video_paths:
462
- raise ValueError("No se descargaron videos utilizables.")
 
463
 
464
  # 5. Procesar y concatenar clips de video
 
465
  current_duration = 0
466
  min_clip_duration = 0.5
467
  max_clip_segment = 10.0
 
468
  for i, path in enumerate(video_paths):
469
  if current_duration >= audio_duration + max_clip_segment:
 
470
  break
 
 
471
  try:
 
472
  clip = VideoFileClip(path)
473
  source_clips.append(clip)
474
- if clip.duration is None or clip.duration <= 0:
 
 
475
  continue
 
476
  remaining_needed = audio_duration - current_duration
477
- segment_duration = min(clip.duration, max_clip_segment, remaining_needed + min_clip_duration)
478
- if segment_duration >= min_clip_duration:
479
- sub = clip.subclip(0, segment_duration)
480
- clips_to_concatenate.append(sub)
481
- current_duration += sub.duration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  except Exception as e:
483
- logger.warning(f"Error procesando video {path}: {str(e)}")
 
 
 
484
 
485
  if not clips_to_concatenate:
486
- raise ValueError("No hay segmentos de video válidos.")
 
487
 
488
- video_base = concatenate_videoclips(clips_to_concatenate, method="chain")
489
- if video_base.duration is None or video_base.duration <= 0:
490
- raise ValueError("Video base inválido.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
 
492
- # Ajustar duración del video
493
- if video_base.duration < audio_duration:
494
- num_full_repeats = int(audio_duration // video_base.duration)
495
- remaining_duration = audio_duration % video_base.duration
496
- repeated_clips_list = [video_base] * num_full_repeats
497
  if remaining_duration > 0:
498
- remaining_clip = video_base.subclip(0, remaining_duration)
499
- repeated_clips_list.append(remaining_clip)
500
- video_base = concatenate_videoclips(repeated_clips_list, method="chain")
501
- elif video_base.duration > audio_duration:
502
- video_base = video_base.subclip(0, audio_duration)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
  # 6. Manejar música de fondo
505
- final_audio = audio_tts
 
 
 
 
 
506
  if musica_file:
 
507
  try:
508
  music_path = os.path.join(temp_dir_intermediate, "musica_bg.mp3")
509
- shutil.copyfile(musica_file.name if hasattr(musica_file, 'name') else musica_file, music_path)
510
  temp_intermediate_files.append(music_path)
 
 
511
  musica_audio_original = AudioFileClip(music_path)
512
- if musica_audio_original.duration > 0:
513
- musica_audio = loop_audio_to_length(musica_audio_original, video_base.duration)
514
- final_audio = CompositeAudioClip([
515
- musica_audio.volumex(0.2),
516
- audio_tts.volumex(1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  ])
 
 
 
 
 
 
 
 
 
 
 
518
  except Exception as e:
519
- logger.warning(f"Error procesando música: {str(e)}")
520
- final_audio = audio_tts
 
 
521
 
522
- if abs(final_audio.duration - video_base.duration) > 0.2:
523
- final_audio = final_audio.subclip(0, video_base.duration)
524
 
525
- # 7. Combinar audio y video
526
- video_final = video_base.set_audio(final_audio)
527
- output_filename = f"video_{int(datetime.now().timestamp())}.mp4"
528
- output_path = os.path.join(temp_dir_intermediate, output_filename)
529
- persistent_dir = "/data"
530
- os.makedirs(persistent_dir, exist_ok=True)
531
- persistent_path = os.path.join(persistent_dir, output_filename)
 
 
 
 
 
 
 
 
 
 
532
 
 
 
 
 
 
 
 
533
  video_final.write_videofile(
534
  output_path,
535
  fps=24,
@@ -537,110 +873,172 @@ async def crear_video_async(prompt_type, input_text, selected_voice, musica_file
537
  codec="libx264",
538
  audio_codec="aac",
539
  preset="medium",
540
- ffmpeg_params=['-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black', '-crf', '23'],
 
 
 
541
  logger='bar'
542
  )
543
 
544
- shutil.move(output_path, persistent_path)
545
- download_url = f"https://gnosticdev-invideo-basic.hf.space/file={persistent_path}"
546
- logger.info(f"Video guardado en: {persistent_path}")
547
- logger.info(f"URL de descarga: {download_url}")
 
 
 
 
 
 
 
 
 
 
 
 
548
  total_time = (datetime.now() - start_time).total_seconds()
549
- logger.info(f"Video generado en {total_time:.2f}s")
550
- return persistent_path, download_url
 
551
 
552
  except ValueError as ve:
553
- logger.error(f"Error controlado: {str(ve)}")
554
- raise
555
  except Exception as e:
556
- logger.critical(f"Error crítico: {str(e)}")
557
- raise
558
  finally:
559
- for clip in source_clips + clips_to_concatenate:
 
 
560
  try:
561
  clip.close()
562
- except:
563
- pass
564
- if audio_tts_original:
565
- try:
566
- audio_tts_original.close()
567
- except:
568
- pass
569
- if musica_audio:
 
 
570
  try:
571
  musica_audio.close()
572
- except:
573
- pass
574
- if musica_audio_original:
575
- try:
576
- musica_audio_original.close()
577
- except:
578
- pass
579
- if video_base:
580
- try:
581
- video_base.close()
582
- except:
583
- pass
584
- if video_final:
 
 
 
 
 
 
 
 
 
585
  try:
586
  video_final.close()
587
- except:
588
- pass
589
- for path in temp_intermediate_files:
590
- if os.path.isfile(path) and path != persistent_path:
591
- try:
592
- os.remove(path)
593
- except:
594
- logger.warning(f"No se pudo eliminar {path}")
595
- try:
596
- if os.path.exists(temp_dir_intermediate):
597
- shutil.rmtree(temp_dir_intermediate)
598
- except:
599
- logger.warning(f"No se pudo eliminar directorio temporal {temp_dir_intermediate}")
600
 
601
- async def run_app_async(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  logger.info("="*80)
603
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
 
 
604
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
 
605
  output_video = None
606
  output_file = None
607
- status_msg = gr.update(value="⏳ Procesando... Esto puede tomar hasta 1 hora.")
608
 
609
  if not input_text or not input_text.strip():
610
  logger.warning("Texto de entrada vacío.")
611
- return None, None, gr.update(value="⚠️ Ingresa texto para el guion o tema.")
 
612
 
 
 
613
  voice_ids_disponibles = [v[1] for v in AVAILABLE_VOICES]
614
  if selected_voice not in voice_ids_disponibles:
615
- logger.warning(f"Voz inválida: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}")
616
- selected_voice = DEFAULT_VOICE_ID
 
 
 
 
 
 
 
 
 
 
 
617
 
618
  try:
619
- logger.info("Iniciando generación de video...")
620
- video_path, download_url = await crear_video_async(prompt_type, input_text, selected_voice, musica_file)
 
 
621
  if video_path and os.path.exists(video_path):
622
- output_video = video_path
623
- output_file = video_path
624
- status_msg = gr.update(value=f"✅ Video generado exitosamente. Descarga: {download_url}")
625
- logger.info(f"Retornando video_path: {video_path}, URL: {download_url}")
 
626
  else:
627
- status_msg = gr.update(value=" Error: Falló la generación del video.")
628
- logger.error("No se generó video_path válido.")
 
629
  except ValueError as ve:
630
- logger.warning(f"Error de validación: {str(ve)}")
631
- status_msg = gr.update(value=f"⚠️ Error: {str(ve)}")
632
  except Exception as e:
633
- logger.critical(f"Error crítico: {str(e)}")
634
- status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}")
635
  finally:
636
- logger.info("Finalizando run_app_async")
637
- return output_video, gr.File(value=output_file, label="Descargar Video"), status_msg
638
 
639
- def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice):
640
- return asyncio.run(run_app_async(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice))
641
 
642
  # Interfaz de Gradio
643
- with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft()) as app:
 
 
 
 
644
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
645
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
646
 
@@ -651,32 +1049,44 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft()) as ap
651
  label="Método de Entrada",
652
  value="Generar Guion con IA"
653
  )
 
 
654
  with gr.Column(visible=True) as ia_guion_column:
655
  prompt_ia = gr.Textbox(
656
  label="Tema para IA",
657
  lines=2,
658
- placeholder="Ej: Un paisaje natural con montañas y ríos al amanecer...",
659
- max_lines=4
 
660
  )
 
661
  with gr.Column(visible=False) as manual_guion_column:
662
  prompt_manual = gr.Textbox(
663
  label="Tu Guion Completo",
664
  lines=5,
665
- placeholder="Ej: En este video exploraremos los misterios del océano...",
666
- max_lines=10
 
667
  )
 
668
  musica_input = gr.Audio(
669
  label="Música de fondo (opcional)",
670
  type="filepath",
671
- interactive=True
 
672
  )
 
 
673
  voice_dropdown = gr.Dropdown(
674
  label="Seleccionar Voz para Guion",
675
  choices=AVAILABLE_VOICES,
676
  value=DEFAULT_VOICE_ID,
677
  interactive=True
678
  )
 
 
679
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
 
680
  with gr.Column():
681
  video_output = gr.Video(
682
  label="Previsualización del Video Generado",
@@ -691,55 +1101,67 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft()) as ap
691
  status_output = gr.Textbox(
692
  label="Estado",
693
  interactive=False,
 
694
  placeholder="Esperando acción...",
695
  value="Esperando entrada..."
696
  )
697
 
 
698
  prompt_type.change(
699
- fn=lambda x: (gr.update(visible=x == "Generar Guion con IA"), gr.update(visible=x == "Usar Mi Guion")),
 
700
  inputs=prompt_type,
701
  outputs=[ia_guion_column, manual_guion_column]
702
  )
703
 
 
704
  generate_btn.click(
705
- fn=lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar hasta 1 hora.")),
706
- outputs=[video_output, file_output, status_output]
 
707
  ).then(
708
- fn=run_app,
709
  inputs=[prompt_type, prompt_ia, prompt_manual, musica_input, voice_dropdown],
710
- outputs=[video_output, file_output, status_output],
711
- queue=True
712
  ).then(
713
- fn=lambda video_path, file_output, status_msg: gr.update(visible=file_output.value is not None),
714
  inputs=[video_output, file_output, status_output],
715
  outputs=[file_output]
716
  )
717
 
718
  gr.Markdown("### Instrucciones:")
719
  gr.Markdown("""
720
- 1. Configura la variable de entorno `PEXELS_API_KEY`.
721
- 2. Selecciona el tipo de entrada: "Generar Guion con IA" o "Usar Mi Guion".
722
- 3. Sube música (opcional).
723
- 4. Selecciona la voz.
724
- 5. Haz clic en "✨ Generar Video".
725
- 6. Revisa el estado. Si el video se genera, estará disponible en /data.
726
- 7. Consulta `video_generator_full.log` para detalles.
 
727
  """)
 
 
728
 
729
  if __name__ == "__main__":
730
- logger.info("Verificando dependencias...")
731
  try:
732
  from moviepy.editor import ColorClip
733
- temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
734
- temp_clip.close()
735
- logger.info("MoviePy y FFmpeg accesibles.")
 
 
 
736
  except Exception as e:
737
- logger.critical(f"Fallo en dependencias: {e}")
738
- raise
739
- os.environ['GRADIO_SERVER_TIMEOUT'] = '3600'
 
 
740
  logger.info("Iniciando aplicación Gradio...")
741
  try:
742
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)
743
  except Exception as e:
744
- logger.critical(f"No se pudo iniciar la app: {str(e)}")
745
  raise
 
32
  logger.info("="*80)
33
 
34
  # Diccionario de voces TTS disponibles organizadas por idioma
35
+ # Puedes expandir esta lista si conoces otros IDs de voz de Edge TTS
36
  VOCES_DISPONIBLES = {
37
  "Español (España)": {
38
  "es-ES-JuanNeural": "Juan (España) - Masculino",
 
100
  choices = []
101
  for region, voices in VOCES_DISPONIBLES.items():
102
  for voice_id, voice_name in voices.items():
103
+ # Formato: (Texto a mostrar en el dropdown, Valor que se pasa)
104
  choices.append((f"{voice_name} ({region})", voice_id))
105
  return choices
106
 
107
  # Obtener las voces al inicio del script
108
+ # Usamos la lista predefinida por ahora para evitar el error de inicio con la API
109
+ # Si deseas obtenerlas dinámicamente, descomenta la siguiente línea y comenta la que usa get_voice_choices()
110
+ # AVAILABLE_VOICES = asyncio.run(get_available_voices())
111
+ AVAILABLE_VOICES = get_voice_choices() # <-- Usamos la lista predefinida y aplanada
112
+ # Establecer una voz por defecto inicial
113
+ DEFAULT_VOICE_ID = "es-ES-JuanNeural" # ID de Juan
114
+
115
+ # Buscar el nombre amigable para la voz por defecto si existe
116
  DEFAULT_VOICE_NAME = DEFAULT_VOICE_ID
117
  for text, voice_id in AVAILABLE_VOICES:
118
  if voice_id == DEFAULT_VOICE_ID:
119
  DEFAULT_VOICE_NAME = text
120
  break
121
+ # Si Juan no está en la lista (ej. lista de fallback), usar la primera voz disponible
122
  if DEFAULT_VOICE_ID not in [v[1] for v in AVAILABLE_VOICES]:
123
+ DEFAULT_VOICE_ID = AVAILABLE_VOICES[0][1] if AVAILABLE_VOICES else "en-US-AriaNeural"
124
+ DEFAULT_VOICE_NAME = AVAILABLE_VOICES[0][0] if AVAILABLE_VOICES else "Aria (United States) - Female" # Fallback name
125
+
126
  logger.info(f"Voz por defecto seleccionada (ID): {DEFAULT_VOICE_ID}")
127
 
128
+
129
  # Clave API de Pexels
130
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
131
  if not PEXELS_API_KEY:
132
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
133
+ # raise ValueError("API key de Pexels no configurada")
134
 
135
  # Inicialización de modelos
136
  MODEL_NAME = "datificate/gpt2-small-spanish"
 
170
  "orientation": "landscape",
171
  "size": "medium"
172
  }
173
+
174
  response = requests.get(
175
  "https://api.pexels.com/videos/search",
176
  headers=headers,
 
178
  timeout=20
179
  )
180
  response.raise_for_status()
181
+
182
  data = response.json()
183
  videos = data.get('videos', [])
184
  logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
185
  return videos
186
+
187
  except requests.exceptions.RequestException as e:
188
  logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
 
189
  except json.JSONDecodeError:
190
+ logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
 
191
  except Exception as e:
192
+ logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
193
+
194
+ return []
195
 
196
  def generate_script(prompt, max_length=150):
197
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
 
207
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
208
  model.to(device)
209
  inputs = {k: v.to(device) for k, v in inputs.items()}
210
+
211
  outputs = model.generate(
212
  **inputs,
213
  max_length=max_length + inputs[list(inputs.keys())[0]].size(1),
 
220
  eos_token_id=tokenizer.eos_token_id,
221
  no_repeat_ngram_size=3
222
  )
223
+
224
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
225
+
226
+ cleaned_text = text.strip()
227
+ # Limpieza mejorada de la frase de instrucción
228
+ try:
229
+ # Buscar el índice de inicio del prompt original dentro del texto generado
230
+ prompt_in_output_idx = text.lower().find(prompt.lower())
231
+ if prompt_in_output_idx != -1:
232
+ # Tomar todo el texto DESPUÉS del prompt original
233
+ cleaned_text = text[prompt_in_output_idx + len(prompt):].strip()
234
+ logger.debug("Texto limpiado tomando parte después del prompt original.")
235
  else:
236
+ # Fallback si el prompt original no está exacto en la salida: buscar la frase de instrucción base
237
+ instruction_start_idx = text.find(instruction_phrase_start)
238
+ if instruction_start_idx != -1:
239
+ # Tomar texto después de la frase base (puede incluir el prompt)
240
+ cleaned_text = text[instruction_start_idx + len(instruction_phrase_start):].strip()
241
+ logger.debug("Texto limpiado tomando parte después de la frase de instrucción base.")
242
+ else:
243
+ # Si ni la frase de instrucción ni el prompt se encuentran, usar el texto original
244
+ logger.warning("No se pudo identificar el inicio del guión generado. Usando texto generado completo.")
245
+ cleaned_text = text.strip() # Limpieza básica
246
+
247
+
248
+ except Exception as e:
249
+ logger.warning(f"Error durante la limpieza heurística del guión de IA: {e}. Usando texto generado sin limpieza adicional.")
250
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip() # Limpieza básica como fallback
251
+
252
+ # Asegurarse de que el texto resultante no sea solo la instrucción o vacío
253
+ if not cleaned_text or len(cleaned_text) < 10: # Umbral de longitud mínima
254
+ logger.warning("El guión generado parece muy corto o vacío después de la limpieza heurística. Usando el texto generado original (sin limpieza adicional).")
255
+ cleaned_text = re.sub(r'<[^>]+>', '', text).strip() # Fallback al texto original limpio
256
+
257
+ # Limpieza final de caracteres especiales y espacios sobrantes
258
  cleaned_text = re.sub(r'<[^>]+>', '', cleaned_text).strip()
259
+ cleaned_text = cleaned_text.lstrip(':').strip() # Quitar posibles ':' al inicio
260
+ cleaned_text = cleaned_text.lstrip('.').strip() # Quitar posibles '.' al inicio
261
+
262
+
263
+ # Intentar obtener al menos una oración completa si es posible para un inicio más limpio
264
  sentences = cleaned_text.split('.')
265
  if sentences and sentences[0].strip():
266
  final_text = sentences[0].strip() + '.'
267
+ # Añadir la segunda oración si existe y es razonable
268
+ if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.7: # Usar un 70% de max_length como umbral
269
+ final_text += " " + sentences[1].strip() + "."
270
+ final_text = final_text.replace("..", ".") # Limpiar doble punto
271
+
272
  logger.info(f"Guion generado final (Truncado a 100 chars): '{final_text[:100]}...'")
273
  return final_text.strip()
274
+
275
+ logger.info(f"Guion generado final (sin oraciones completas detectadas - Truncado): '{cleaned_text[:100]}...'")
276
+ return cleaned_text.strip() # Si no se puede formar una oración, devolver el texto limpio tal cual
277
+
278
  except Exception as e:
279
+ logger.error(f"Error generando guion con GPT-2 (fuera del bloque de limpieza): {str(e)}", exc_info=True)
280
+ logger.warning("Usando prompt original como guion debido al error de generación.")
281
  return prompt.strip()
282
 
283
+ # Función TTS ahora recibe la voz a usar
284
  async def text_to_speech(text, output_path, voice):
285
+ logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice} | Salida: {output_path}")
286
  if not text or not text.strip():
287
  logger.warning("Texto vacío para TTS")
288
  return False
289
+
290
  try:
291
  communicate = edge_tts.Communicate(text, voice)
292
  await communicate.save(output_path)
 
 
 
 
 
 
293
 
 
 
 
294
  if os.path.exists(output_path) and os.path.getsize(output_path) > 100:
295
+ logger.info(f"Audio guardado exitosamente en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
296
  return True
297
+ else:
298
+ logger.error(f"TTS guardó un archivo pequeño o vacío en: {output_path}")
299
+ return False
300
+
301
  except Exception as e:
302
+ logger.error(f"Error en TTS con voz '{voice}': {str(e)}", exc_info=True)
303
  return False
304
 
305
  def download_video_file(url, temp_dir):
306
  if not url:
307
+ logger.warning("URL de video no proporcionada para descargar")
308
  return None
309
+
310
  try:
311
  logger.info(f"Descargando video desde: {url[:80]}...")
312
  os.makedirs(temp_dir, exist_ok=True)
313
  file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
314
  output_path = os.path.join(temp_dir, file_name)
315
+
316
  with requests.get(url, stream=True, timeout=60) as r:
317
  r.raise_for_status()
318
  with open(output_path, 'wb') as f:
319
  for chunk in r.iter_content(chunk_size=8192):
320
  f.write(chunk)
321
+
322
  if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
323
+ logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
324
+ return output_path
325
+ else:
326
+ logger.warning(f"Descarga parece incompleta o vacía para {url[:80]}... Archivo: {output_path} Tamaño: {os.path.getsize(output_path) if os.path.exists(output_path) else 'N/A'} bytes")
327
+ if os.path.exists(output_path):
328
+ os.remove(output_path)
329
+ return None
330
+
331
  except requests.exceptions.RequestException as e:
332
+ logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
 
333
  except Exception as e:
334
+ logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
335
+
336
+ return None
337
 
338
  def loop_audio_to_length(audio_clip, target_duration):
339
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
340
+
341
  if audio_clip is None or audio_clip.duration is None or audio_clip.duration <= 0:
342
+ logger.warning("Input audio clip is invalid (None or zero duration), cannot loop.")
343
+ try:
344
+ sr = getattr(audio_clip, 'fps', 44100) if audio_clip else 44100
345
+ return AudioClip(lambda t: 0, duration=target_duration, sr=sr)
346
+ except Exception as e:
347
+ logger.error(f"Could not create silence clip: {e}", exc_info=True)
348
+ return AudioFileClip(filename="")
349
+
350
  if audio_clip.duration >= target_duration:
351
+ logger.debug("Audio clip already longer or equal to target. Trimming.")
352
+ trimmed_clip = audio_clip.subclip(0, target_duration)
353
+ if trimmed_clip.duration is None or trimmed_clip.duration <= 0:
354
+ logger.error("Trimmed audio clip is invalid.")
355
+ try: trimmed_clip.close()
356
+ except: pass
357
+ return AudioFileClip(filename="")
358
+ return trimmed_clip
359
+
360
  loops = math.ceil(target_duration / audio_clip.duration)
361
  logger.debug(f"Creando {loops} loops de audio")
362
+
363
+ audio_segments = [audio_clip] * loops
364
+ looped_audio = None
365
+ final_looped_audio = None
366
  try:
367
+ looped_audio = concatenate_audioclips(audio_segments)
368
+
369
+ if looped_audio.duration is None or looped_audio.duration <= 0:
370
+ logger.error("Concatenated audio clip is invalid (None or zero duration).")
371
+ raise ValueError("Invalid concatenated audio.")
372
+
373
+ final_looped_audio = looped_audio.subclip(0, target_duration)
374
+
375
+ if final_looped_audio.duration is None or final_looped_audio.duration <= 0:
376
+ logger.error("Final subclipped audio clip is invalid (None or zero duration).")
377
+ raise ValueError("Invalid final subclipped audio.")
378
+
379
+ return final_looped_audio
380
+
381
  except Exception as e:
382
+ logger.error(f"Error concatenating/subclipping audio clips for looping: {str(e)}", exc_info=True)
383
+ try:
384
+ if audio_clip.duration is not None and audio_clip.duration > 0:
385
+ logger.warning("Returning original audio clip (may be too short).")
386
+ return audio_clip.subclip(0, min(audio_clip.duration, target_duration))
387
+ except:
388
+ pass
389
+ logger.error("Fallback to original audio clip failed.")
390
+ return AudioFileClip(filename="")
391
+
392
+ finally:
393
+ if looped_audio is not None and looped_audio is not final_looped_audio:
394
+ try: looped_audio.close()
395
+ except: pass
396
+
397
 
398
  def extract_visual_keywords_from_script(script_text):
399
  logger.info("Extrayendo palabras clave del guion")
400
  if not script_text or not script_text.strip():
401
+ logger.warning("Guion vacío, no se pueden extraer palabras clave.")
402
  return ["naturaleza", "ciudad", "paisaje"]
403
+
404
  clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
405
+ keywords_list = []
406
+
407
  if kw_model:
408
  try:
409
+ logger.debug("Intentando extracción con KeyBERT...")
410
  keywords1 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(1, 1), stop_words='spanish', top_n=5)
411
  keywords2 = kw_model.extract_keywords(clean_text, keyphrase_ngram_range=(2, 2), stop_words='spanish', top_n=3)
412
+
413
  all_keywords = keywords1 + keywords2
414
  all_keywords.sort(key=lambda item: item[1], reverse=True)
415
+
416
  seen_keywords = set()
417
+ for keyword, score in all_keywords:
418
  formatted_keyword = keyword.lower().replace(" ", "+")
419
  if formatted_keyword and formatted_keyword not in seen_keywords:
420
  keywords_list.append(formatted_keyword)
421
  seen_keywords.add(formatted_keyword)
422
  if len(keywords_list) >= 5:
423
  break
424
+
425
  if keywords_list:
426
  logger.debug(f"Palabras clave extraídas por KeyBERT: {keywords_list}")
427
  return keywords_list
428
+
429
  except Exception as e:
430
+ logger.warning(f"KeyBERT falló: {str(e)}. Intentando método simple.")
431
+
432
  logger.debug("Extrayendo palabras clave con método simple...")
433
  words = clean_text.lower().split()
434
+ stop_words = {"el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con", "para", "del", "al", "por", "su", "sus", "se", "lo", "le", "me", "te", "nos", "os", "les", "mi", "tu",
435
+ "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus",
436
+ "nuestros", "vuestros", "estas", "esas", "aquellas", "si", "no", "más", "menos", "sin", "sobre", "bajo", "entre", "hasta", "desde", "durante", "mediante", "según", "versus", "via", "cada", "todo", "todos", "toda", "todas", "poco", "pocos", "poca", "pocas", "mucho", "muchos", "mucha", "muchas", "varios", "varias", "otro", "otros", "otra", "otras", "mismo", "misma", "mismos", "mismas", "tan", "tanto", "tanta", "tantos", "tantas", "tal", "tales", "cual", "cuales", "cuyo", "cuya", "cuyos", "cuyas", "quien", "quienes", "cuan", "cuanto", "cuanta", "cuantos", "cuantas", "como", "donde", "cuando", "porque", "aunque", "mientras", "siempre", "nunca", "jamás", "muy", "casi", "solo", "solamente", "incluso", "apenas", "quizás", "tal vez", "acaso", "claro", "cierto", "obvio", "evidentemente", "realmente", "simplemente", "generalmente", "especialmente", "principalmente", "posiblemente", "probablemente", "difícilmente", "fácilmente", "rápidamente", "lentamente", "bien", "mal", "mejor", "peor", "arriba", "abajo", "adelante", "atrás", "cerca", "lejos", "dentro", "fuera", "encima", "debajo", "frente", "detrás", "antes", "después", "luego", "pronto", "tarde", "todavía", "ya", "aun", "aún", "quizá"}
437
+
438
  valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
439
+
440
  if not valid_words:
441
+ logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
442
+ return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
443
+
444
  word_counts = Counter(valid_words)
445
  top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
446
+
447
+ if not top_keywords:
448
+ logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
449
+ return ["espiritual", "terror", "matrix", "arcontes", "galaxia", "creepy", "magia", "gangstalking","conspiracy",]
450
+
451
  logger.info(f"Palabras clave finales: {top_keywords}")
452
  return top_keywords
453
 
454
+ # crear_video ahora recibe la voz seleccionada
455
+ def crear_video(prompt_type, input_text, selected_voice, musica_file=None):
456
  logger.info("="*80)
457
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
458
  logger.debug(f"Input: '{input_text[:100]}...'")
459
  logger.info(f"Voz seleccionada: {selected_voice}")
460
+
461
  start_time = datetime.now()
462
+ temp_dir_intermediate = None
463
+
 
464
  audio_tts_original = None
465
  musica_audio_original = None
466
  audio_tts = None
 
472
 
473
  try:
474
  # 1. Generar o usar guion
475
+ if prompt_type == "Generar Guion con IA":
476
+ guion = generate_script(input_text)
477
+ else:
478
+ guion = input_text.strip()
479
+
480
  logger.info(f"Guion final ({len(guion)} chars): '{guion[:100]}...'")
481
+
482
  if not guion.strip():
483
+ logger.error("El guion resultante está vacío o solo contiene espacios.")
484
  raise ValueError("El guion está vacío.")
485
 
486
+ temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
487
+ logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
488
+ temp_intermediate_files = []
489
+
490
+ # 2. Generar audio de voz usando la voz seleccionada, con reintentos si falla
491
+ logger.info("Generando audio de voz...")
492
  voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
493
+
494
+ tts_voices_to_try = [selected_voice]
495
+ fallback_juan = "es-ES-JuanNeural"
496
+ fallback_elvira = "es-ES-ElviraNeural"
497
+
498
+ if fallback_juan and fallback_juan != selected_voice and fallback_juan not in tts_voices_to_try:
499
+ tts_voices_to_try.append(fallback_juan)
500
+ if fallback_elvira and fallback_elvira != selected_voice and fallback_elvira not in tts_voices_to_try:
501
+ tts_voices_to_try.append(fallback_elvira)
502
+
503
  tts_success = False
504
+ tried_voices = set()
 
 
505
 
506
  for current_voice in tts_voices_to_try:
507
+ if not current_voice or current_voice in tried_voices: continue
508
+ tried_voices.add(current_voice)
509
+
510
+ logger.info(f"Intentando TTS con voz: {current_voice}...")
511
  try:
512
+ tts_success = asyncio.run(text_to_speech(guion, voz_path, voice=current_voice))
513
+ if tts_success:
514
+ logger.info(f"TTS exitoso con voz '{current_voice}'.")
515
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  except Exception as e:
517
+ logger.warning(f"Fallo al generar TTS con voz '{current_voice}': {str(e)}", exc_info=True)
518
+ pass
519
 
520
  if not tts_success or not os.path.exists(voz_path) or os.path.getsize(voz_path) <= 100:
521
+ logger.error("Fallo en la generación de voz después de todos los intentos. Archivo de audio no creado o es muy pequeño.")
522
+ raise ValueError("Error generando voz a partir del guion (fallo de TTS).")
523
 
524
  temp_intermediate_files.append(voz_path)
525
+
526
  audio_tts_original = AudioFileClip(voz_path)
527
+
528
+ if audio_tts_original.reader is None or audio_tts_original.duration is None or audio_tts_original.duration <= 0:
529
+ logger.critical("Clip de audio TTS inicial es inválido (reader is None o duración <= 0) *después* de crear AudioFileClip.")
530
+ try: audio_tts_original.close()
531
+ except: pass
532
+ audio_tts_original = None
533
+ if os.path.exists(voz_path):
534
+ try: os.remove(voz_path)
535
+ except: pass
536
+ if voz_path in temp_intermediate_files:
537
+ temp_intermediate_files.remove(voz_path)
538
+
539
+ raise ValueError("Audio de voz generado es inválido después de procesamiento inicial.")
540
+
541
  audio_tts = audio_tts_original
542
  audio_duration = audio_tts_original.duration
543
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
 
 
544
 
545
+ if audio_duration < 1.0:
546
+ logger.error(f"Duración audio voz ({audio_duration:.2f}s) es muy corta.")
547
+ raise ValueError("Generated voice audio is too short (min 1 second required).")
548
  # 3. Extraer palabras clave
549
+ logger.info("Extrayendo palabras clave...")
550
+ try:
551
+ keywords = extract_visual_keywords_from_script(guion)
552
+ logger.info(f"Palabras clave identificadas: {keywords}")
553
+ except Exception as e:
554
+ logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
555
+ keywords = ["naturaleza", "paisaje"]
556
+
557
  if not keywords:
558
+ keywords = ["video", "background"]
 
559
 
560
  # 4. Buscar y descargar videos
561
+ logger.info("Buscando videos en Pexels...")
562
  videos_data = []
563
  total_desired_videos = 10
564
  per_page_per_keyword = max(1, total_desired_videos // len(keywords))
565
+
566
  for keyword in keywords:
567
+ if len(videos_data) >= total_desired_videos: break
568
+ try:
569
+ videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
570
+ if videos:
571
+ videos_data.extend(videos)
572
+ logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
573
+ except Exception as e:
574
+ logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
575
 
576
  if len(videos_data) < total_desired_videos / 2:
577
+ logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
578
+ 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"]
579
  for keyword in generic_keywords:
580
+ if len(videos_data) >= total_desired_videos: break
581
+ try:
582
+ videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
583
+ if videos:
584
+ videos_data.extend(videos)
585
+ logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
586
+ except Exception as e:
587
+ logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
588
 
589
  if not videos_data:
590
+ logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
591
+ raise ValueError("No se encontraron videos adecuados en Pexels.")
592
 
593
  video_paths = []
594
+ logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
595
  for video in videos_data:
596
  if 'video_files' not in video or not video['video_files']:
597
+ logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
598
  continue
 
 
 
 
 
 
599
 
600
+ try:
601
+ best_quality = None
602
+ for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
603
+ if 'link' in vf:
604
+ best_quality = vf
605
+ break
606
+
607
+ if best_quality and 'link' in best_quality:
608
+ path = download_video_file(best_quality['link'], temp_dir_intermediate)
609
+ if path:
610
+ video_paths.append(path)
611
+ temp_intermediate_files.append(path)
612
+ logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
613
+ else:
614
+ logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
615
+ else:
616
+ logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
617
+
618
+ except Exception as e:
619
+ logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
620
+
621
+ logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
622
  if not video_paths:
623
+ logger.error("No se pudo descargar ningún archivo de video utilizable.")
624
+ raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
625
 
626
  # 5. Procesar y concatenar clips de video
627
+ logger.info("Procesando y concatenando videos descargados...")
628
  current_duration = 0
629
  min_clip_duration = 0.5
630
  max_clip_segment = 10.0
631
+
632
  for i, path in enumerate(video_paths):
633
  if current_duration >= audio_duration + max_clip_segment:
634
+ logger.debug(f"Video base suficiente ({current_duration:.1f}s >= {audio_duration:.1f}s + {max_clip_segment:.1f}s buffer). Dejando de procesar clips fuente restantes.")
635
  break
636
+
637
+ clip = None
638
  try:
639
+ logger.debug(f"[{i+1}/{len(video_paths)}] Abriendo clip: {path}")
640
  clip = VideoFileClip(path)
641
  source_clips.append(clip)
642
+
643
+ if clip.reader is None or clip.duration is None or clip.duration <= 0:
644
+ logger.warning(f"[{i+1}/{len(video_paths)}] Clip fuente {path} parece inválido (reader is None o duración <= 0). Saltando.")
645
  continue
646
+
647
  remaining_needed = audio_duration - current_duration
648
+ potential_use_duration = min(clip.duration, max_clip_segment)
649
+
650
+ if remaining_needed > 0:
651
+ segment_duration = min(potential_use_duration, remaining_needed + min_clip_duration)
652
+ segment_duration = max(min_clip_duration, segment_duration)
653
+ segment_duration = min(segment_duration, clip.duration)
654
+
655
+ if segment_duration >= min_clip_duration:
656
+ try:
657
+ sub = clip.subclip(0, segment_duration)
658
+ if sub.reader is None or sub.duration is None or sub.duration <= 0:
659
+ logger.warning(f"[{i+1}/{len(video_paths)}] Subclip generado de {path} es inválido. Saltando.")
660
+ try: sub.close()
661
+ except: pass
662
+ continue
663
+
664
+ clips_to_concatenate.append(sub)
665
+ current_duration += sub.duration
666
+ logger.debug(f"[{i+1}/{len(video_paths)}] Segmento añadido: {sub.duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
667
+
668
+ except Exception as sub_e:
669
+ logger.warning(f"[{i+1}/{len(video_paths)}] Error creando subclip de {path} ({segment_duration:.1f}s): {str(sub_e)}")
670
+ continue
671
+ else:
672
+ logger.debug(f"[{i+1}/{len(video_paths)}] Clip {path} ({clip.duration:.1f}s) no contribuye un segmento suficiente ({segment_duration:.1f}s necesario). Saltando.")
673
+ else:
674
+ logger.debug(f"[{i+1}/{len(video_paths)}] Duración de video base ya alcanzada. Saltando clip.")
675
+
676
  except Exception as e:
677
+ logger.warning(f"[{i+1}/{len(video_paths)}] Error procesando video {path}: {str(e)}", exc_info=True)
678
+ continue
679
+
680
+ logger.info(f"Procesamiento de clips fuente finalizado. Se obtuvieron {len(clips_to_concatenate)} segmentos válidos.")
681
 
682
  if not clips_to_concatenate:
683
+ logger.error("No hay segmentos de video válidos disponibles para crear la secuencia.")
684
+ raise ValueError("No hay segmentos de video válidos disponibles para crear el video.")
685
 
686
+ logger.info(f"Concatenando {len(clips_to_concatenate)} segmentos de video.")
687
+ concatenated_base = None
688
+ try:
689
+ concatenated_base = concatenate_videoclips(clips_to_concatenate, method="chain")
690
+ logger.info(f"Duración video base después de concatenación inicial: {concatenated_base.duration:.2f}s")
691
+
692
+ if concatenated_base is None or concatenated_base.duration is None or concatenated_base.duration <= 0:
693
+ logger.critical("Video base concatenado es inválido después de la primera concatenación (None o duración cero).")
694
+ raise ValueError("Fallo al crear video base válido a partir de segmentos.")
695
+
696
+ except Exception as e:
697
+ logger.critical(f"Error durante la concatenación inicial: {str(e)}", exc_info=True)
698
+ raise ValueError("Fallo durante la concatenación de video inicial.")
699
+ finally:
700
+ for clip_segment in clips_to_concatenate:
701
+ try: clip_segment.close()
702
+ except: pass
703
+ clips_to_concatenate = []
704
+
705
+ video_base = concatenated_base
706
+
707
+ final_video_base = video_base
708
+
709
+ if final_video_base.duration < audio_duration:
710
+ logger.info(f"Video base ({final_video_base.duration:.2f}s) es más corto que el audio ({audio_duration:.2f}s). Repitiendo...")
711
 
712
+ num_full_repeats = int(audio_duration // final_video_base.duration)
713
+ remaining_duration = audio_duration % final_video_base.duration
714
+
715
+ repeated_clips_list = [final_video_base] * num_full_repeats
 
716
  if remaining_duration > 0:
717
+ try:
718
+ remaining_clip = final_video_base.subclip(0, remaining_duration)
719
+ if remaining_clip is None or remaining_clip.duration is None or remaining_clip.duration <= 0:
720
+ logger.warning(f"Subclip generado para duración restante {remaining_duration:.2f}s es inválido. Saltando.")
721
+ try: remaining_clip.close()
722
+ except: pass
723
+ else:
724
+ repeated_clips_list.append(remaining_clip)
725
+ logger.debug(f"Añadiendo segmento restante: {remaining_duration:.2f}s")
726
+
727
+ except Exception as e:
728
+ logger.warning(f"Error creando subclip para duración restante {remaining_duration:.2f}s: {str(e)}")
729
+
730
+ if repeated_clips_list:
731
+ logger.info(f"Concatenando {len(repeated_clips_list)} partes para repetición.")
732
+ video_base_repeated = None
733
+ try:
734
+ video_base_repeated = concatenate_videoclips(repeated_clips_list, method="chain")
735
+ logger.info(f"Duración del video base repetido: {video_base_repeated.duration:.2f}s")
736
+
737
+ if video_base_repeated is None or video_base_repeated.duration is None or video_base_repeated.duration <= 0:
738
+ logger.critical("Video base repetido concatenado es inválido.")
739
+ raise ValueError("Fallo al crear video base repetido válido.")
740
+
741
+ if final_video_base is not video_base_repeated:
742
+ try: final_video_base.close()
743
+ except: pass
744
+
745
+ final_video_base = video_base_repeated
746
+
747
+ except Exception as e:
748
+ logger.critical(f"Error durante la concatenación de repetición: {str(e)}", exc_info=True)
749
+ raise ValueError("Fallo durante la repetición de video.")
750
+ finally:
751
+ if 'repeated_clips_list' in locals():
752
+ for clip in repeated_clips_list:
753
+ if clip is not final_video_base:
754
+ try: clip.close()
755
+ except: pass
756
+
757
+
758
+ if final_video_base.duration > audio_duration:
759
+ logger.info(f"Recortando video base ({final_video_base.duration:.2f}s) para que coincida con la duración del audio ({audio_duration:.2f}s).")
760
+ trimmed_video_base = None
761
+ try:
762
+ trimmed_video_base = final_video_base.subclip(0, audio_duration)
763
+ if trimmed_video_base is None or trimmed_video_base.duration is None or trimmed_video_base.duration <= 0:
764
+ logger.critical("Video base recortado es inválido.")
765
+ raise ValueError("Fallo al crear video base recortado válido.")
766
+
767
+ if final_video_base is not trimmed_video_base:
768
+ try: final_video_base.close()
769
+ except: pass
770
+
771
+ final_video_base = trimmed_video_base
772
+
773
+ except Exception as e:
774
+ logger.critical(f"Error durante el recorte: {str(e)}", exc_info=True)
775
+ raise ValueError("Fallo durante el recorte de video.")
776
+
777
+
778
+ if final_video_base is None or final_video_base.duration is None or final_video_base.duration <= 0:
779
+ logger.critical("Video base final es inválido antes de audio/escritura (None o duración cero).")
780
+ raise ValueError("Video base final es inválido.")
781
+
782
+ if final_video_base.size is None or final_video_base.size[0] <= 0 or final_video_base.size[1] <= 0:
783
+ logger.critical(f"Video base final tiene tamaño inválido: {final_video_base.size}. No se puede escribir video.")
784
+ raise ValueError("Video base final tiene tamaño inválido antes de escribir.")
785
+
786
+ video_base = final_video_base
787
 
788
  # 6. Manejar música de fondo
789
+ logger.info("Procesando audio...")
790
+
791
+ final_audio = audio_tts_original
792
+
793
+ musica_audio_looped = None
794
+
795
  if musica_file:
796
+ musica_audio_original = None
797
  try:
798
  music_path = os.path.join(temp_dir_intermediate, "musica_bg.mp3")
799
+ shutil.copyfile(musica_file, music_path)
800
  temp_intermediate_files.append(music_path)
801
+ logger.info(f"Música de fondo copiada a: {music_path}")
802
+
803
  musica_audio_original = AudioFileClip(music_path)
804
+
805
+ if musica_audio_original.reader is None or musica_audio_original.duration is None or musica_audio_original.duration <= 0:
806
+ logger.warning("Clip de música de fondo parece inválido o tiene duración cero. Saltando música.")
807
+ try: musica_audio_original.close()
808
+ except: pass
809
+ musica_audio_original = None
810
+ else:
811
+ musica_audio_looped = loop_audio_to_length(musica_audio_original, video_base.duration)
812
+ logger.debug(f"Música ajustada a duración del video: {musica_audio_looped.duration:.2f}s")
813
+
814
+ if musica_audio_looped is None or musica_audio_looped.duration is None or musica_audio_looped.duration <= 0:
815
+ logger.warning("Clip de música de fondo loopeado es inválido. Saltando música.")
816
+ try: musica_audio_looped.close()
817
+ except: pass
818
+ musica_audio_looped = None
819
+
820
+
821
+ if musica_audio_looped:
822
+ composite_audio = CompositeAudioClip([
823
+ musica_audio_looped.volumex(0.2), # Volumen 20% para música
824
+ audio_tts_original.volumex(1.0) # Volumen 100% para voz
825
  ])
826
+
827
+ if composite_audio.duration is None or composite_audio.duration <= 0:
828
+ logger.warning("Clip de audio compuesto es inválido (None o duración cero). Usando solo audio de voz.")
829
+ try: composite_audio.close()
830
+ except: pass
831
+ final_audio = audio_tts_original
832
+ else:
833
+ logger.info("Mezcla de audio completada (voz + música).")
834
+ final_audio = composite_audio
835
+ musica_audio = musica_audio_looped # Asignar para limpieza
836
+
837
  except Exception as e:
838
+ logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
839
+ final_audio = audio_tts_original
840
+ musica_audio = None
841
+ logger.warning("Usando solo audio de voz debido a un error con la música.")
842
 
 
 
843
 
844
+ if final_audio.duration is not None and abs(final_audio.duration - video_base.duration) > 0.2:
845
+ logger.warning(f"Duración del audio final ({final_audio.duration:.2f}s) difiere significativamente del video base ({video_base.duration:.2f}s). Intentando recorte.")
846
+ try:
847
+ if final_audio.duration > video_base.duration:
848
+ trimmed_final_audio = final_audio.subclip(0, video_base.duration)
849
+ if trimmed_final_audio is None or trimmed_final_audio.duration <= 0:
850
+ logger.warning("Audio final recortado es inválido. Usando audio final original.")
851
+ try: trimmed_final_audio.close()
852
+ except: pass
853
+ else:
854
+ if final_audio is not trimmed_final_audio:
855
+ try: final_audio.close()
856
+ except: pass
857
+ final_audio = trimmed_final_audio
858
+ logger.warning("Audio final recortado para que coincida con la duración del video.")
859
+ except Exception as e:
860
+ logger.warning(f"Error ajustando duración del audio final: {str(e)}")
861
 
862
+ try:
863
+
864
+ output_filename = f"video_{int(time.time())}.mp4" # Nombre único con timestamp
865
+ output_path = os.path.join(temp_dir_intermediate, output_filename)
866
+ permanent_path = f"/tmp/{output_filename}"
867
+
868
+ # Escribir el video
869
  video_final.write_videofile(
870
  output_path,
871
  fps=24,
 
873
  codec="libx264",
874
  audio_codec="aac",
875
  preset="medium",
876
+ ffmpeg_params=[
877
+ '-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:-1:-1:color=black',
878
+ '-crf', '23'
879
+ ],
880
  logger='bar'
881
  )
882
 
883
+ # Mover a ubicación permanente en /tmp
884
+ try:
885
+ shutil.copy(output_path, permanent_path) # Usamos copy() en lugar de move()
886
+ logger.info(f"Video guardado permanentemente en: {permanent_path}")
887
+ except Exception as move_error:
888
+ logger.error(f"Error moviendo archivo: {str(move_error)}. Usando path original.")
889
+ permanent_path = output_path
890
+
891
+ # Cierra los clips para liberar memoria
892
+ try:
893
+ video_final.close()
894
+ if 'video_base' in locals() and video_base is not None and video_base is not video_final:
895
+ video_base.close()
896
+ except Exception as close_error:
897
+ logger.error(f"Error cerrando clips: {str(close_error)}")
898
+
899
  total_time = (datetime.now() - start_time).total_seconds()
900
+ logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {permanent_path} | Tiempo total: {total_time:.2f}s")
901
+
902
+ return permanent_path
903
 
904
  except ValueError as ve:
905
+ logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
906
+ raise ve
907
  except Exception as e:
908
+ logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
909
+ raise e
910
  finally:
911
+ logger.info("Iniciando limpieza de clips y archivos temporales intermedios...")
912
+
913
+ for clip in source_clips:
914
  try:
915
  clip.close()
916
+ except Exception as e:
917
+ logger.warning(f"Error cerrando clip de video fuente en finally: {str(e)}")
918
+
919
+ for clip_segment in clips_to_concatenate:
920
+ try:
921
+ clip_segment.close()
922
+ except Exception as e:
923
+ logger.warning(f"Error cerrando segmento de video en finally: {str(e)}")
924
+
925
+ if musica_audio is not None:
926
  try:
927
  musica_audio.close()
928
+ except Exception as e:
929
+ logger.warning(f"Error cerrando musica_audio (procesada) en finally: {str(e)}")
930
+
931
+ if musica_audio_original is not None and musica_audio_original is not musica_audio:
932
+ try:
933
+ musica_audio_original.close()
934
+ except Exception as e:
935
+ logger.warning(f"Error cerrando musica_audio_original en finally: {str(e)}")
936
+
937
+ if audio_tts is not None and audio_tts is not audio_tts_original:
938
+ try:
939
+ audio_tts.close()
940
+ except Exception as e:
941
+ logger.warning(f"Error cerrando audio_tts (procesada) en finally: {str(e)}")
942
+
943
+ if audio_tts_original is not None:
944
+ try:
945
+ audio_tts_original.close()
946
+ except Exception as e:
947
+ logger.warning(f"Error cerrando audio_tts_original en finally: {str(e)}")
948
+
949
+ if video_final is not None:
950
  try:
951
  video_final.close()
952
+ except Exception as e:
953
+ logger.warning(f"Error cerrando video_final en finally: {str(e)}")
954
+ elif video_base is not None and video_base is not video_final:
955
+ try:
956
+ video_base.close()
957
+ except Exception as e:
958
+ logger.warning(f"Error cerrando video_base en finally: {str(e)}")
 
 
 
 
 
 
959
 
960
+ if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
961
+ final_output_in_temp = os.path.join(temp_dir_intermediate, output_filename)
962
+
963
+ for path in temp_intermediate_files:
964
+ try:
965
+ if os.path.isfile(path) and path != final_output_in_temp and path != permanent_path:
966
+ logger.debug(f"Eliminando archivo temporal intermedio: {path}")
967
+ os.remove(path)
968
+ elif os.path.isfile(path) and (path == final_output_in_temp or path == permanent_path):
969
+ logger.debug(f"Saltando eliminación del archivo de video final: {path}")
970
+ except Exception as e:
971
+ logger.warning(f"No se pudo eliminar archivo temporal intermedio {path}: {str(e)}")
972
+
973
+ logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
974
+
975
+ # run_app ahora recibe todos los inputs, incluyendo la voz seleccionada
976
+ def run_app(prompt_type, prompt_ia, prompt_manual, musica_file, selected_voice): # <-- Recibe el valor del Dropdown
977
  logger.info("="*80)
978
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
979
+
980
+ # Elegir el texto de entrada basado en el prompt_type
981
  input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
982
+
983
  output_video = None
984
  output_file = None
985
+ status_msg = gr.update(value="⏳ Procesando...", interactive=False)
986
 
987
  if not input_text or not input_text.strip():
988
  logger.warning("Texto de entrada vacío.")
989
+ # Retornar None para video y archivo, actualizar estado con mensaje de error
990
+ return None, None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.", interactive=False)
991
 
992
+ # Validar la voz seleccionada. Si no es válida, usar la por defecto.
993
+ # AVAILABLE_VOICES se obtiene al inicio. Hay que buscar si el voice_id existe en la lista de pares (nombre, id)
994
  voice_ids_disponibles = [v[1] for v in AVAILABLE_VOICES]
995
  if selected_voice not in voice_ids_disponibles:
996
+ logger.warning(f"Voz seleccionada inválida o no encontrada en la lista: '{selected_voice}'. Usando voz por defecto: {DEFAULT_VOICE_ID}.")
997
+ selected_voice = DEFAULT_VOICE_ID # <-- Usar el ID de la voz por defecto
998
+ else:
999
+ logger.info(f"Voz seleccionada validada: {selected_voice}")
1000
+
1001
+
1002
+ logger.info(f"Tipo de entrada: {prompt_type}")
1003
+ logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
1004
+ if musica_file:
1005
+ logger.info(f"Archivo de música recibido: {musica_file}")
1006
+ else:
1007
+ logger.info("No se proporcionó archivo de música.")
1008
+ logger.info(f"Voz final a usar (ID): {selected_voice}") # Loguear el ID de la voz final
1009
 
1010
  try:
1011
+ logger.info("Llamando a crear_video...")
1012
+ # Pasar el input_text elegido, la voz seleccionada (el ID) y el archivo de música a crear_video
1013
+ video_path = crear_video(prompt_type, input_text, selected_voice, musica_file) # <-- PASAR selected_voice (ID) a crear_video
1014
+
1015
  if video_path and os.path.exists(video_path):
1016
+ logger.info(f"crear_video retornó path: {video_path}")
1017
+ logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
1018
+ output_video = video_path # Establecer valor del componente de video
1019
+ output_file = video_path # Establecer valor del componente de archivo para descarga
1020
+ status_msg = gr.update(value="✅ Video generado exitosamente.", interactive=False)
1021
  else:
1022
+ logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
1023
+ status_msg = gr.update(value=" Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
1024
+
1025
  except ValueError as ve:
1026
+ logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
1027
+ status_msg = gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
1028
  except Exception as e:
1029
+ logger.critical(f"Error crítico durante la creación del video: {str(e)}", exc_info=True)
1030
+ status_msg = gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
1031
  finally:
1032
+ logger.info("Fin del handler run_app.")
1033
+ return output_video, output_file, status_msg
1034
 
 
 
1035
 
1036
  # Interfaz de Gradio
1037
+ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
1038
+ .gradio-container {max-width: 800px; margin: auto;}
1039
+ h1 {text-align: center;}
1040
+ """) as app:
1041
+
1042
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
1043
  gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
1044
 
 
1049
  label="Método de Entrada",
1050
  value="Generar Guion con IA"
1051
  )
1052
+
1053
+ # Contenedores para los campos de texto para controlar la visibilidad
1054
  with gr.Column(visible=True) as ia_guion_column:
1055
  prompt_ia = gr.Textbox(
1056
  label="Tema para IA",
1057
  lines=2,
1058
+ placeholder="Ej: Un paisaje natural con montañas y ríos al amanecer, mostrando la belleza de la naturaleza...",
1059
+ max_lines=4,
1060
+ value=""
1061
  )
1062
+
1063
  with gr.Column(visible=False) as manual_guion_column:
1064
  prompt_manual = gr.Textbox(
1065
  label="Tu Guion Completo",
1066
  lines=5,
1067
+ placeholder="Ej: En este video exploraremos los misterios del océano. Veremos la vida marina fascinante y los arrecifes de coral vibrantes. ¡Acompáñanos en esta aventura subacuática!",
1068
+ max_lines=10,
1069
+ value=""
1070
  )
1071
+
1072
  musica_input = gr.Audio(
1073
  label="Música de fondo (opcional)",
1074
  type="filepath",
1075
+ interactive=True,
1076
+ value=None
1077
  )
1078
+
1079
+ # --- COMPONENTE: Selección de Voz ---
1080
  voice_dropdown = gr.Dropdown(
1081
  label="Seleccionar Voz para Guion",
1082
  choices=AVAILABLE_VOICES,
1083
  value=DEFAULT_VOICE_ID,
1084
  interactive=True
1085
  )
1086
+ # --- FIN COMPONENTE ---
1087
+
1088
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
1089
+
1090
  with gr.Column():
1091
  video_output = gr.Video(
1092
  label="Previsualización del Video Generado",
 
1101
  status_output = gr.Textbox(
1102
  label="Estado",
1103
  interactive=False,
1104
+ show_label=False,
1105
  placeholder="Esperando acción...",
1106
  value="Esperando entrada..."
1107
  )
1108
 
1109
+ # Evento para mostrar/ocultar los campos de texto según el tipo de prompt
1110
  prompt_type.change(
1111
+ lambda x: (gr.update(visible=x == "Generar Guion con IA"),
1112
+ gr.update(visible=x == "Usar Mi Guion")),
1113
  inputs=prompt_type,
1114
  outputs=[ia_guion_column, manual_guion_column]
1115
  )
1116
 
1117
+ # Evento click del botón de generar video
1118
  generate_btn.click(
1119
+ lambda: (None, None, gr.update(value="⏳ Procesando... Esto puede tomar varios minutos.", interactive=False)),
1120
+ outputs=[video_output, file_output, status_output],
1121
+ queue=True,
1122
  ).then(
1123
+ run_app,
1124
  inputs=[prompt_type, prompt_ia, prompt_manual, musica_input, voice_dropdown],
1125
+ outputs=[video_output, file_output, status_output]
 
1126
  ).then(
1127
+ lambda video_path, file_path, status_msg: gr.update(visible=file_path is not None),
1128
  inputs=[video_output, file_output, status_output],
1129
  outputs=[file_output]
1130
  )
1131
 
1132
  gr.Markdown("### Instrucciones:")
1133
  gr.Markdown("""
1134
+ 1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
1135
+ 2. **Selecciona el tipo de entrada**: "Generar Guion con IA" o "Usar Mi Guion".
1136
+ 3. **Sube música** (opcional): Selecciona un archio de audio (MP3, WAV, etc.).
1137
+ 4. **Selecciona la voz** deseada del desplegable.
1138
+ 5. **Haz clic en "✨ Generar Video"**.
1139
+ 6. Espera a que se procese el video. Verás el estado.
1140
+ 7. La previsualización aparecerá si es posible, y siempre un enlace **Descargar Archivo de Video** se mostrará si la generación fue exitosa.
1141
+ 8. Revisa `video_generator_full.log` para detalles si hay errores.
1142
  """)
1143
+ gr.Markdown("---")
1144
+ gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]")
1145
 
1146
  if __name__ == "__main__":
1147
+ logger.info("Verificando dependencias críticas...")
1148
  try:
1149
  from moviepy.editor import ColorClip
1150
+ try:
1151
+ temp_clip = ColorClip((100,100), color=(255,0,0), duration=0.1)
1152
+ temp_clip.close()
1153
+ logger.info("Clips base de MoviePy creados y cerrados exitosamente. FFmpeg parece accesible.")
1154
+ except Exception as e:
1155
+ logger.critical(f"Fallo al crear clip base de MoviePy. A menudo indica problemas con FFmpeg/ImageMagick. Error: {e}", exc_info=True)
1156
  except Exception as e:
1157
+ logger.critical(f"Fallo al importar MoviePy. Asegúrate de que está instalado. Error: {e}", exc_info=True)
1158
+
1159
+ # Solución para el timeout de Gradio
1160
+ os.environ['GRADIO_SERVER_TIMEOUT'] = '1200' # 1200 segundos = 10 minutos
1161
+
1162
  logger.info("Iniciando aplicación Gradio...")
1163
  try:
1164
  app.launch(server_name="0.0.0.0", server_port=7860, share=False)
1165
  except Exception as e:
1166
+ logger.critical(f"No se pudo iniciar la app: {str(e)}", exc_info=True)
1167
  raise