gnosticdev commited on
Commit
43865d4
·
verified ·
1 Parent(s): d058c4e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +548 -192
app.py CHANGED
@@ -7,12 +7,16 @@ from datetime import datetime
7
  import edge_tts
8
  import gradio as gr
9
  import torch
 
10
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
11
  from keybert import KeyBERT
12
- from moviepy.editor import VideoFileClip, concatenate_videoclips, AudioFileClip, CompositeAudioClip
 
13
  import re
14
  import math
15
- from pydub import AudioSegment
 
 
16
  from collections import Counter
17
  import shutil
18
  import json
@@ -32,36 +36,47 @@ logger.info("INICIO DE EJECUCIÓN - GENERADOR DE VIDEOS")
32
  logger.info("="*80)
33
 
34
  # Clave API de Pexels (configuración segura)
 
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
  else:
40
  logger.info("API key de Pexels configurada correctamente")
41
 
42
  # Inicialización de modelos CON LOGS DETALLADOS
43
  MODEL_NAME = "datificate/gpt2-small-spanish"
44
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
 
 
45
  try:
46
- tokenizer = GPTizer.from_pretrained(MODEL_NAME)
 
47
  model = GPT2LMHeadModel.from_pretrained(MODEL_NAME).eval()
48
  if tokenizer.pad_token is None:
49
  tokenizer.pad_token = tokenizer.eos_token
50
  logger.info(f"Modelo GPT-2 cargado | Vocabulario: {len(tokenizer)} tokens")
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
  try:
57
  kw_model = KeyBERT('distilbert-base-multilingual-cased')
58
  logger.info("KeyBERT inicializado correctamente")
59
  except Exception as e:
60
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
61
- kw_model = None
62
 
63
  # [FUNCIÓN BUSCAR_VIDEOS_PEXELS ORIGINAL CON LOGS AÑADIDOS]
64
  def buscar_videos_pexels(query, api_key, per_page=5):
 
 
 
 
65
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
66
  headers = {"Authorization": api_key}
67
  try:
@@ -79,20 +94,21 @@ def buscar_videos_pexels(query, api_key, per_page=5):
79
  params=params,
80
  timeout=20
81
  )
82
- response.raise_for_status()
83
-
84
  try:
85
  data = response.json()
86
- logger.info(f"Pexels: {len(data.get('videos', []))} videos encontrados")
87
- return data.get('videos', [])
 
88
  except json.JSONDecodeError:
89
- logger.error(f"Pexels: JSON inválido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
90
  return []
91
 
92
  except requests.exceptions.RequestException as e:
93
- logger.error(f"Error de conexión Pexels: {str(e)}")
94
  except Exception as e:
95
- logger.error(f"Error inesperado Pexels: {str(e)}", exc_info=True)
96
 
97
  return []
98
 
@@ -100,47 +116,83 @@ def buscar_videos_pexels(query, api_key, per_page=5):
100
  def generate_script(prompt, max_length=150):
101
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
102
  if not tokenizer or not model:
103
- logger.warning("Modelos no disponibles - Usando prompt original")
104
  return prompt
105
 
106
  try:
107
- enhanced_prompt = f"Escribe un guion corto y coherente sobre: {prompt}"
 
 
 
 
 
 
 
108
  inputs = tokenizer(enhanced_prompt, return_tensors="pt", truncation=True, max_length=512)
109
 
110
  logger.debug("Generando texto con GPT-2...")
 
 
 
 
 
 
111
  outputs = model.generate(
112
  **inputs,
113
  max_length=max_length,
114
- do_sample=True,
115
  top_p=0.9,
116
  top_k=40,
117
  temperature=0.7,
118
- repetition_penalty=1.5,
119
  pad_token_id=tokenizer.pad_token_id,
120
- eos_token_id=tokenizer.eos_token_id
 
121
  )
122
 
123
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
124
- text = re.sub(r'<[^>]+>', '', text)
 
 
 
 
 
125
  sentences = text.split('.')
 
 
 
 
 
 
 
 
 
126
 
127
- if sentences:
128
- final_text = sentences[0] + '.'
129
- logger.info(f"Guion generado: '{final_text[:100]}...'")
130
- return final_text
131
- return text
132
  except Exception as e:
133
- logger.error(f"Error generando guion: {str(e)}", exc_info=True)
134
- return prompt
 
135
 
136
  # [FUNCIÓN TEXT_TO_SPEECH ORIGINAL CON LOGS]
137
  async def text_to_speech(text, output_path, voice="es-ES-ElviraNeural"):
138
- logger.info(f"Convirtiendo texto a voz | Caracteres: {len(text)} | Voz: {voice}")
 
 
 
 
139
  try:
140
  communicate = edge_tts.Communicate(text, voice)
141
  await communicate.save(output_path)
142
- logger.info(f"Audio guardado en: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
143
- return True
 
 
 
 
 
 
144
  except Exception as e:
145
  logger.error(f"Error en TTS: {str(e)}", exc_info=True)
146
  return False
@@ -148,279 +200,540 @@ async def text_to_speech(text, output_path, voice="es-ES-ElviraNeural"):
148
  # [FUNCIÓN DOWNLOAD_VIDEO_FILE ORIGINAL CON LOGS]
149
  def download_video_file(url, temp_dir):
150
  if not url:
151
- logger.warning("URL de video no proporcionada")
152
  return None
153
 
154
  try:
155
- logger.info(f"Descargando video desde: {url[:50]}...")
156
- file_name = f"video_{datetime.now().strftime('%H%M%S%f')}.mp4"
 
 
157
  output_path = os.path.join(temp_dir, file_name)
158
 
159
- with requests.get(url, stream=True, timeout=30) as r:
 
160
  r.raise_for_status()
 
 
 
 
161
  with open(output_path, 'wb') as f:
162
  for chunk in r.iter_content(chunk_size=8192):
163
  f.write(chunk)
164
-
165
- logger.info(f"Video descargado: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
166
- return output_path
 
 
 
 
 
 
 
 
 
 
 
 
167
  except Exception as e:
168
- logger.error(f"Error descargando video: {str(e)}", exc_info=True)
169
- return None
 
170
 
171
  # [FUNCIÓN LOOP_AUDIO_TO_LENGTH ORIGINAL CON LOGS]
172
  def loop_audio_to_length(audio_clip, target_duration):
173
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
 
 
 
 
174
  if audio_clip.duration >= target_duration:
 
175
  return audio_clip.subclip(0, target_duration)
176
 
 
177
  loops = math.ceil(target_duration / audio_clip.duration)
178
  logger.debug(f"Creando {loops} loops de audio")
179
- audios = [audio_clip] * loops
180
- return concatenate_videoclips(audios).subclip(0, target_duration)
 
 
 
 
 
 
 
181
 
182
  # [FUNCIÓN EXTRACT_VISUAL_KEYWORDS_FROM_SCRIPT ORIGINAL CON LOGS]
183
  def extract_visual_keywords_from_script(script_text):
184
  logger.info("Extrayendo palabras clave del guion")
185
- clean_text = re.sub(r'[^\w\sáéíóúñ]', '', script_text.lower())
 
 
 
 
 
186
 
 
 
187
  if kw_model:
188
  try:
189
- keywords = kw_model.extract_keywords(
 
 
190
  clean_text,
191
  keyphrase_ngram_range=(1, 1),
192
  stop_words='spanish',
 
 
 
 
 
 
193
  top_n=3
194
  )
195
- if keywords:
196
- logger.debug(f"KeyBERT keywords: {keywords}")
197
- return [kw[0].replace(" ", "+") for kw in keywords]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  except Exception as e:
199
- logger.warning(f"KeyBERT falló: {str(e)}")
 
200
 
201
- words = clean_text.split()
202
- stop_words = {"el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con"}
203
- keywords = [word for word in words if len(word) > 3 and word not in stop_words]
 
 
 
 
 
204
 
205
- if not keywords:
206
- logger.warning("Usando palabras clave predeterminadas")
207
  return ["naturaleza", "ciudad", "paisaje"]
208
 
209
- word_counts = Counter(keywords)
210
- top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(3)]
 
 
 
 
 
 
 
211
  logger.info(f"Palabras clave finales: {top_keywords}")
212
  return top_keywords
213
 
214
- # [FUNCIÓN CREAR_VIDEO ORIGINAL CON LOGS]
215
  def crear_video(prompt_type, input_text, musica_file=None):
216
  logger.info("="*80)
217
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
218
  logger.debug(f"Input: '{input_text[:100]}...'")
219
 
220
- # 1. Generar o usar guion
221
  start_time = datetime.now()
222
- if prompt_type == "Generar Guion con IA":
223
- logger.info("Generando guion con IA...")
224
- guion = generate_script(input_text)
225
- else:
226
- logger.info("Usando guion proporcionado")
227
- guion = input_text
228
-
229
- logger.info(f"Guion final ({len(guion)} caracteres): '{guion[:100]}...'")
230
-
231
- if not guion.strip():
232
- logger.error("El guion está vacío")
233
- raise ValueError("El guion está vacío")
234
-
235
- # Directorio temporal
236
- temp_dir = tempfile.mkdtemp()
237
- logger.info(f"Directorio temporal creado: {temp_dir}")
238
- temp_files = []
239
-
240
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  # 2. Generar audio de voz
242
  logger.info("Generando audio de voz...")
243
- voz_path = os.path.join(temp_dir, "voz.mp3")
 
 
244
  if not asyncio.run(text_to_speech(guion, voz_path)):
245
- logger.error("Fallo en generación de voz")
246
- raise ValueError("Error generando voz")
247
- temp_files.append(voz_path)
248
-
249
  audio_tts = AudioFileClip(voz_path)
250
  audio_duration = audio_tts.duration
251
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
252
-
 
 
 
 
253
  # 3. Extraer palabras clave
254
  logger.info("Extrayendo palabras clave...")
255
  try:
256
  keywords = extract_visual_keywords_from_script(guion)
257
  logger.info(f"Palabras clave identificadas: {keywords}")
258
  except Exception as e:
259
- logger.error(f"Error extrayendo keywords: {str(e)}")
260
- keywords = ["naturaleza", "paisaje"]
261
-
 
 
 
 
262
  # 4. Buscar y descargar videos
263
  logger.info("Buscando videos en Pexels...")
264
  videos_data = []
 
 
 
 
265
  for keyword in keywords:
 
266
  try:
267
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=3)
268
  if videos:
269
  videos_data.extend(videos)
270
- logger.info(f"Encontrados {len(videos)} videos para '{keyword}'")
271
  except Exception as e:
272
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
273
-
274
- if not videos_data:
275
- logger.warning("No se encontraron videos - Usando palabras clave genéricas")
276
- for keyword in ["naturaleza", "ciudad", "paisaje"]:
277
- videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=3)
278
- if videos:
279
- videos_data.extend(videos)
280
-
 
 
 
 
 
 
 
 
 
281
  if not videos_data:
282
- logger.error("No se encontraron videos para ninguna palabra clave")
283
- raise ValueError("No se encontraron videos en Pexels para ninguna palabra clave")
284
 
285
  video_paths = []
 
286
  for video in videos_data:
287
  if 'video_files' not in video or not video['video_files']:
 
288
  continue
289
 
290
  try:
291
- best_quality = max(
292
- video['video_files'],
293
- key=lambda x: x.get('width', 0) * x.get('height', 0)
294
- )
295
-
296
- if 'link' in best_quality:
297
- path = download_video_file(best_quality['link'], temp_dir)
 
 
298
  if path:
299
  video_paths.append(path)
300
- temp_files.append(path)
301
- logger.info(f"Video descargado: {best_quality['link']}")
 
 
 
 
 
302
  except Exception as e:
303
- logger.warning(f"Error procesando video: {str(e)}")
304
-
 
305
  if not video_paths:
306
- logger.error("No se pudo descargar ningún video")
307
- raise ValueError("No se pudo descargar ningún video")
308
 
309
- # 5. Procesar videos
310
- logger.info("Procesando videos descargados...")
311
  clips = []
312
  current_duration = 0
313
-
 
 
 
 
 
314
  for path in video_paths:
315
- if current_duration >= audio_duration:
 
 
316
  break
317
-
318
  try:
 
319
  clip = VideoFileClip(path)
320
- usable_duration = min(clip.duration, 10)
321
 
322
- if usable_duration > 1:
323
- clips.append(clip.subclip(0, usable_duration))
324
- current_duration += usable_duration
325
- logger.debug(f"Clip añadido: {usable_duration:.1f}s (total: {current_duration:.1f}/{audio_duration:.1f}s)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  except Exception as e:
327
- logger.warning(f"Error procesando video {path}: {str(e)}")
328
-
 
 
329
  if not clips:
330
- logger.error("No hay clips válidos para crear el video")
331
- raise ValueError("No hay clips válidos para crear el video")
332
 
 
333
  video_base = concatenate_videoclips(clips, method="compose")
334
- logger.info(f"Duración base del video: {video_base.duration:.2f}s")
335
 
 
336
  if video_base.duration < audio_duration:
 
 
337
  num_repeats = math.ceil(audio_duration / video_base.duration)
338
- logger.info(f"Repitiendo video {num_repeats} veces para ajustar duración")
339
- video_base = concatenate_videoclips([video_base] * num_repeats).subclip(0, audio_duration)
340
-
 
 
 
 
 
 
 
 
 
 
341
  # 6. Manejar música de fondo
342
  logger.info("Procesando audio...")
343
- final_audio = audio_tts
344
 
345
  if musica_file:
346
  try:
347
- music_path = os.path.join(temp_dir, "musica.mp3")
 
348
  shutil.copyfile(musica_file, music_path)
349
- temp_files.append(music_path)
350
- logger.info(f"Música copiada a: {music_path}")
351
 
352
  musica_audio = AudioFileClip(music_path)
353
  logger.debug(f"Duración música original: {musica_audio.duration:.2f}s")
354
 
355
- if musica_audio.duration < audio_duration:
356
- musica_audio = loop_audio_to_length(musica_audio, audio_duration)
357
- logger.debug(f"Música looped: {musica_audio.duration:.2f}s")
358
 
 
359
  final_audio = CompositeAudioClip([
360
- musica_audio.volumex(0.3),
361
- audio_tts.volumex(1.0)
362
  ])
363
- logger.info("Mezcla de audio completada")
364
  except Exception as e:
365
- logger.warning(f"Error procesando música: {str(e)}")
366
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  # 7. Crear video final
368
  logger.info("Renderizando video final...")
369
  video_final = video_base.set_audio(final_audio)
370
- output_path = os.path.join(temp_dir, "final_video.mp4")
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  video_final.write_videofile(
373
  output_path,
374
  fps=24,
375
- threads=4,
376
- codec="libx264",
377
- audio_codec="aac",
378
- preset="medium",
379
- logger=None
 
 
380
  )
381
 
382
  total_time = (datetime.now() - start_time).total_seconds()
383
- logger.info(f"VIDEO FINALIZADO: {output_path} | Tiempo total: {total_time:.2f}s")
384
- return output_path
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  except Exception as e:
387
- logger.error(f"ERROR EN CREAR_VIDEO: {str(e)}", exc_info=True)
388
- raise
389
- finally:
390
- logger.info("Limpiando archivos temporales...")
391
- for path in temp_files:
392
- try:
393
- if os.path.isfile(path):
394
- os.remove(path)
395
- except Exception as e:
396
- logger.warning(f"No se pudo eliminar {path}: {str(e)}")
397
-
398
  try:
399
- shutil.rmtree(temp_dir, ignore_errors=True)
400
- except Exception as e:
401
- logger.warning(f"No se pudo eliminar {temp_dir}: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
  # [FUNCIÓN RUN_APP ORIGINAL CON LOGS]
404
  def run_app(prompt_type, prompt_ia, prompt_manual, musica_file):
405
  logger.info("="*80)
406
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
407
- input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
408
 
409
- if not input_text.strip():
410
- logger.warning("Texto de entrada vacío")
411
- return None, "Por favor ingresa texto"
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  try:
414
- logger.info("Iniciando creación de video...")
415
  video_path = crear_video(prompt_type, input_text, musica_file)
416
- logger.info("Video creado exitosamente")
417
- return video_path, "✅ Video generado exitosamente"
 
 
 
 
 
 
 
418
  except ValueError as ve:
419
- logger.warning(f"Error de validación: {str(ve)}")
420
- return None, f"⚠️ {ve}"
421
  except Exception as e:
422
- logger.error(f"Error crítico: {str(e)}", exc_info=True)
423
- return None, f"❌ Error: {str(e)}"
 
 
 
424
 
425
  # [INTERFAZ DE GRADIO ORIGINAL COMPLETA]
426
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
@@ -429,35 +742,39 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
429
  """) as app:
430
 
431
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
 
432
 
433
  with gr.Row():
434
  with gr.Column():
435
  prompt_type = gr.Radio(
436
- ["Generar Guion con IA", "Usar Mi Guion"],
437
  label="Método de Entrada",
438
- value="Generar Guion con IA"
439
  )
440
 
441
  with gr.Column(visible=True) as ia_guion_column:
442
  prompt_ia = gr.Textbox(
443
  label="Tema para IA",
444
  lines=2,
445
- placeholder="Ej: Un paisaje natural con montañas y ríos...",
446
- max_lines=4
 
447
  )
448
 
449
  with gr.Column(visible=False) as manual_guion_column:
450
  prompt_manual = gr.Textbox(
451
  label="Tu Guion Completo",
452
  lines=5,
453
- placeholder="Ej: En este video exploraremos los misterios del océano...",
454
- max_lines=10
 
455
  )
456
 
457
  musica_input = gr.Audio(
458
  label="Música de fondo (opcional)",
459
  type="filepath",
460
- interactive=True
 
461
  )
462
 
463
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
@@ -465,16 +782,18 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
465
  with gr.Column():
466
  video_output = gr.Video(
467
  label="Video Generado",
468
- interactive=False,
469
- height=400
470
  )
471
  status_output = gr.Textbox(
472
  label="Estado",
473
- interactive=False,
474
- show_label=False,
475
- placeholder="Esperando acción..."
 
476
  )
477
 
 
478
  prompt_type.change(
479
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
480
  gr.update(visible=x == "Usar Mi Guion")),
@@ -482,11 +801,17 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
482
  outputs=[ia_guion_column, manual_guion_column]
483
  )
484
 
 
 
 
485
  generate_btn.click(
486
- lambda: (None, "⏳ Procesando... (esto puede tomar 2-5 minutos)"),
 
487
  outputs=[video_output, status_output],
488
- queue=False
 
489
  ).then(
 
490
  run_app,
491
  inputs=[prompt_type, prompt_ia, prompt_manual, musica_input],
492
  outputs=[video_output, status_output]
@@ -494,18 +819,49 @@ with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="
494
 
495
  gr.Markdown("### Instrucciones:")
496
  gr.Markdown("""
497
- 1. **Selecciona el tipo de entrada**:
498
- - "Generar Guion con IA": Describe un tema
499
- - "Usar Mi Guion": Escribe tu guion completo
500
- 2. **Sube música** (opcional): Selecciona un archivo de audio
501
- 3. **Haz clic en Generar Video"
502
- 4. Espera a que se procese el video (puede tomar varios minutos)
 
 
503
  """)
 
 
504
 
505
  if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  logger.info("Iniciando aplicación Gradio...")
507
  try:
508
- app.launch(server_name="0.0.0.0", server_port=7860)
 
 
509
  except Exception as e:
510
  logger.critical(f"No se pudo iniciar la app: {str(e)}", exc_info=True)
511
- raise
 
7
  import edge_tts
8
  import gradio as gr
9
  import torch
10
+ # Corregir typo: GPT2Tokenizer en lugar de GPTizer
11
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
12
  from keybert import KeyBERT
13
+ # Importar concatenate_audioclips específicamente si no está en editor por defecto
14
+ from moviepy.editor import VideoFileClip, concatenate_videoclips, AudioFileClip, CompositeAudioClip, concatenate_audioclips
15
  import re
16
  import math
17
+ # Pydub ya no parece usarse directamente en las funciones principales,
18
+ # pero se mantiene por si acaso o por si se usó en intentos previos.
19
+ # from pydub import AudioSegment # Comentado porque no se usa en el flujo principal corregido
20
  from collections import Counter
21
  import shutil
22
  import json
 
36
  logger.info("="*80)
37
 
38
  # Clave API de Pexels (configuración segura)
39
+ # Asegúrate de que esta variable de entorno esté configurada en tu entorno.
40
  PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY")
41
  if not PEXELS_API_KEY:
42
  logger.critical("NO SE ENCONTRÓ PEXELS_API_KEY EN VARIABLES DE ENTORNO")
43
+ # Permite que continúe en desarrollo/test pero registra el problema
44
+ # raise ValueError("API key de Pexels no configurada") # Descomentar para forzar fallo si no está
45
+ logger.warning("Continuando sin PEXELS_API_KEY. La búsqueda de videos fallará.")
46
  else:
47
  logger.info("API key de Pexels configurada correctamente")
48
 
49
  # Inicialización de modelos CON LOGS DETALLADOS
50
  MODEL_NAME = "datificate/gpt2-small-spanish"
51
  logger.info(f"Inicializando modelo GPT-2: {MODEL_NAME}")
52
+ tokenizer = None # Inicializar a None por si falla
53
+ model = None # Inicializar a None por si falla
54
  try:
55
+ # CORRECCIÓN: Usar GPT2Tokenizer
56
+ tokenizer = GPT2Tokenizer.from_pretrained(MODEL_NAME)
57
  model = GPT2LMHeadModel.from_pretrained(MODEL_NAME).eval()
58
  if tokenizer.pad_token is None:
59
  tokenizer.pad_token = tokenizer.eos_token
60
  logger.info(f"Modelo GPT-2 cargado | Vocabulario: {len(tokenizer)} tokens")
61
  except Exception as e:
62
  logger.error(f"FALLA CRÍTICA al cargar GPT-2: {str(e)}", exc_info=True)
63
+ tokenizer = model = None # Asegurar que queden como None
64
 
65
  logger.info("Cargando modelo KeyBERT...")
66
+ kw_model = None # Inicializar a None por si falla
67
  try:
68
  kw_model = KeyBERT('distilbert-base-multilingual-cased')
69
  logger.info("KeyBERT inicializado correctamente")
70
  except Exception as e:
71
  logger.error(f"FALLA al cargar KeyBERT: {str(e)}", exc_info=True)
72
+ kw_model = None # Asegurar que quede como None
73
 
74
  # [FUNCIÓN BUSCAR_VIDEOS_PEXELS ORIGINAL CON LOGS AÑADIDOS]
75
  def buscar_videos_pexels(query, api_key, per_page=5):
76
+ if not api_key:
77
+ logger.warning("No se puede buscar en Pexels: API Key no configurada.")
78
+ return []
79
+
80
  logger.debug(f"Buscando en Pexels: '{query}' | Resultados: {per_page}")
81
  headers = {"Authorization": api_key}
82
  try:
 
94
  params=params,
95
  timeout=20
96
  )
97
+ response.raise_for_status() # Lanza una excepción para códigos de estado de error HTTP
98
+
99
  try:
100
  data = response.json()
101
+ videos = data.get('videos', [])
102
+ logger.info(f"Pexels: {len(videos)} videos encontrados para '{query}'")
103
+ return videos
104
  except json.JSONDecodeError:
105
+ logger.error(f"Pexels: JSON inválido recibido | Status: {response.status_code} | Respuesta: {response.text[:200]}...")
106
  return []
107
 
108
  except requests.exceptions.RequestException as e:
109
+ logger.error(f"Error de conexión Pexels para '{query}': {str(e)}")
110
  except Exception as e:
111
+ logger.error(f"Error inesperado Pexels para '{query}': {str(e)}", exc_info=True)
112
 
113
  return []
114
 
 
116
  def generate_script(prompt, max_length=150):
117
  logger.info(f"Generando guión | Prompt: '{prompt[:50]}...' | Longitud máxima: {max_length}")
118
  if not tokenizer or not model:
119
+ logger.warning("Modelos GPT-2 no disponibles - Usando prompt original como guion.")
120
  return prompt
121
 
122
  try:
123
+ # Intenta mejorar el prompt para la IA si se detecta que es muy corto o no es una pregunta
124
+ # (Esto es una heurística simple, podrías mejorarlo)
125
+ if len(prompt.split()) < 5 or not re.search(r'[¿?.]', prompt):
126
+ enhanced_prompt = f"Escribe un guion corto, interesante y coherente sobre: {prompt}"
127
+ else:
128
+ enhanced_prompt = prompt # Si parece ya un guion o pregunta, úsalo directamente
129
+ logger.debug("Usando prompt original como base para la generación.")
130
+
131
  inputs = tokenizer(enhanced_prompt, return_tensors="pt", truncation=True, max_length=512)
132
 
133
  logger.debug("Generando texto con GPT-2...")
134
+ # Asegurar que el modelo esté en modo evaluación y en la CPU si no hay GPU
135
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
136
+ model.to(device)
137
+ inputs = {k: v.to(device) for k, v in inputs.items()}
138
+
139
+
140
  outputs = model.generate(
141
  **inputs,
142
  max_length=max_length,
143
+ do_sample=True, # Permite creatividad
144
  top_p=0.9,
145
  top_k=40,
146
  temperature=0.7,
147
+ repetition_penalty=1.2, # Reducido un poco para menos repetición agresiva
148
  pad_token_id=tokenizer.pad_token_id,
149
+ eos_token_id=tokenizer.eos_token_id,
150
+ no_repeat_ngram_size=3 # Ayuda a evitar repeticiones de frases cortas
151
  )
152
 
153
  text = tokenizer.decode(outputs[0], skip_special_tokens=True)
154
+
155
+ # Limpieza del texto
156
+ text = re.sub(r'<[^>]+>', '', text) # Elimina posibles tags HTML/XML si los hay
157
+ text = text.strip()
158
+
159
+ # Intenta obtener al menos una oración completa si es posible
160
  sentences = text.split('.')
161
+ if sentences and sentences[0].strip():
162
+ final_text = sentences[0].strip() + '.'
163
+ # Añadir la segunda oración si existe y es razonable
164
+ if len(sentences) > 1 and sentences[1].strip() and len(final_text.split()) < max_length * 0.5: # Si la primera es corta y el guion no muy largo aún
165
+ final_text += " " + sentences[1].strip() + "."
166
+ final_text = final_text.replace("..", ".") # Limpiar doble punto
167
+
168
+ logger.info(f"Guion generado (Truncado a 100 chars): '{final_text[:100]}...'")
169
+ return final_text.strip() # Asegurar que no haya espacios al inicio/fin
170
 
171
+ logger.info(f"Guion generado (sin oraciones completas detectadas): '{text[:100]}...'")
172
+ return text.strip() # Si no se puede formar una oración, devolver el texto tal cual
 
 
 
173
  except Exception as e:
174
+ logger.error(f"Error generando guion con GPT-2: {str(e)}", exc_info=True)
175
+ logger.warning("Usando prompt original como guion debido al error de generación.")
176
+ return prompt.strip() # En caso de error, devolver el prompt original limpio
177
 
178
  # [FUNCIÓN TEXT_TO_SPEECH ORIGINAL CON LOGS]
179
  async def text_to_speech(text, output_path, voice="es-ES-ElviraNeural"):
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: # Check for minimum size
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: {str(e)}", exc_info=True)
198
  return False
 
200
  # [FUNCIÓN DOWNLOAD_VIDEO_FILE ORIGINAL CON LOGS]
201
  def download_video_file(url, temp_dir):
202
  if not url:
203
+ logger.warning("URL de video no proporcionada para descargar")
204
  return None
205
 
206
  try:
207
+ logger.info(f"Descargando video desde: {url[:80]}...")
208
+ # Usar un nombre más robusto y asegurar que el directorio exista
209
+ os.makedirs(temp_dir, exist_ok=True)
210
+ file_name = f"video_dl_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.mp4"
211
  output_path = os.path.join(temp_dir, file_name)
212
 
213
+ # Aumentar timeout para descargas grandes
214
+ with requests.get(url, stream=True, timeout=60) as r:
215
  r.raise_for_status()
216
+ total_size = int(r.headers.get('content-length', 0))
217
+ downloaded_size = 0
218
+ logger.debug(f"Tamaño esperado: {total_size} bytes")
219
+
220
  with open(output_path, 'wb') as f:
221
  for chunk in r.iter_content(chunk_size=8192):
222
  f.write(chunk)
223
+ downloaded_size += len(chunk)
224
+ # Opcional: loguear progreso si es muy lento
225
+
226
+ # Verificar si el archivo descargado tiene un tamaño razonable
227
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 1000: # Mínimo 1KB
228
+ logger.info(f"Video descargado exitosamente: {output_path} | Tamaño: {os.path.getsize(output_path)} bytes")
229
+ return output_path
230
+ else:
231
+ 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")
232
+ if os.path.exists(output_path):
233
+ os.remove(output_path) # Eliminar archivo inválido
234
+ return None
235
+
236
+ except requests.exceptions.RequestException as e:
237
+ logger.error(f"Error de descarga para {url[:80]}... : {str(e)}")
238
  except Exception as e:
239
+ logger.error(f"Error inesperado descargando {url[:80]}... : {str(e)}", exc_info=True)
240
+
241
+ return None
242
 
243
  # [FUNCIÓN LOOP_AUDIO_TO_LENGTH ORIGINAL CON LOGS]
244
  def loop_audio_to_length(audio_clip, target_duration):
245
  logger.debug(f"Ajustando audio | Duración actual: {audio_clip.duration:.2f}s | Objetivo: {target_duration:.2f}s")
246
+ if audio_clip.duration <= 0:
247
+ logger.warning("Clip de audio de duración cero o negativa, no se puede ajustar.")
248
+ return AudioFileClip(filename="") # Devuelve clip de audio vacío
249
+
250
  if audio_clip.duration >= target_duration:
251
+ logger.debug("Clip de audio ya es más largo o igual al objetivo.")
252
  return audio_clip.subclip(0, target_duration)
253
 
254
+ # CORRECCIÓN: Usar concatenate_audioclips para audio
255
  loops = math.ceil(target_duration / audio_clip.duration)
256
  logger.debug(f"Creando {loops} loops de audio")
257
+
258
+ # Crear una lista de clips de audio
259
+ audio_segments = [audio_clip] * loops
260
+
261
+ # Concatenar los clips de audio
262
+ looped_audio = concatenate_audioclips(audio_segments)
263
+
264
+ # Recortar al target_duration exacto
265
+ return looped_audio.subclip(0, target_duration)
266
 
267
  # [FUNCIÓN EXTRACT_VISUAL_KEYWORDS_FROM_SCRIPT ORIGINAL CON LOGS]
268
  def extract_visual_keywords_from_script(script_text):
269
  logger.info("Extrayendo palabras clave del guion")
270
+ if not script_text or not script_text.strip():
271
+ logger.warning("Guion vacío, no se pueden extraer palabras clave.")
272
+ return ["naturaleza", "ciudad", "paisaje"] # Keywords por defecto
273
+
274
+ # Limpieza de texto más robusta, manteniendo espacios para keyphrases
275
+ clean_text = re.sub(r'[^\w\sáéíóúñÁÉÍÓÚÑ]', '', script_text)
276
 
277
+ keywords_list = []
278
+
279
  if kw_model:
280
  try:
281
+ logger.debug("Intentando extraer keywords con KeyBERT...")
282
+ # Intentar con ngrams 1 y 2
283
+ keywords1 = kw_model.extract_keywords(
284
  clean_text,
285
  keyphrase_ngram_range=(1, 1),
286
  stop_words='spanish',
287
+ top_n=5
288
+ )
289
+ keywords2 = kw_model.extract_keywords(
290
+ clean_text,
291
+ keyphrase_ngram_range=(2, 2),
292
+ stop_words='spanish',
293
  top_n=3
294
  )
295
+
296
+ # Combinar y priorizar palabras clave (ngram 1) y frases clave (ngram 2)
297
+ all_keywords = keywords1 + keywords2
298
+ # Ordenar por score descendente
299
+ all_keywords.sort(key=lambda item: item[1], reverse=True)
300
+
301
+ # Tomar los top N únicos
302
+ seen_keywords = set()
303
+ for keyword, score in all_keywords:
304
+ # Convertir a minúsculas y reemplazar espacios por + para URL
305
+ formatted_keyword = keyword.lower().replace(" ", "+")
306
+ if formatted_keyword not in seen_keywords:
307
+ keywords_list.append(formatted_keyword)
308
+ seen_keywords.add(formatted_keyword)
309
+ if len(keywords_list) >= 5: # Limitar el número total de keywords
310
+ break
311
+
312
+ if keywords_list:
313
+ logger.debug(f"KeyBERT keywords extraídas: {keywords_list}")
314
+ return keywords_list
315
+
316
  except Exception as e:
317
+ logger.warning(f"KeyBERT falló durante la extracción: {str(e)}. Intentando método simple.")
318
+ # Continúa al método simple si KeyBERT falla
319
 
320
+ # Método simple de extracción si KeyBERT no está disponible o falló
321
+ logger.debug("Extrayendo keywords con método simple...")
322
+ words = clean_text.lower().split()
323
+ # Conjunto de stop words más amplio y eficiente
324
+ stop_words = {"el", "la", "los", "las", "de", "en", "y", "a", "que", "es", "un", "una", "con", "para", "del", "al", "por", "su", "sus", "se", "lo", "le", "me", "te", "nos", "os", "les", "mi", "tu", "nuestro", "vuestro", "este", "ese", "aquel", "esta", "esa", "aquella", "esto", "eso", "aquello", "mis", "tus", "nuestros", "vuestros", "estas", "esas", "aquellas", "si", "no", "más", "menos", "sin", "sobre", "bajo", "entre", "hasta", "desde", "durante", "mediante", "según", "versus", "via", "cada", "todo", "todos", "toda", "todas", "poco", "pocos", "poca", "pocas", "mucho", "muchos", "mucha", "muchas", "varios", "varias", "otro", "otros", "otra", "otras", "mismo", "misma", "mismos", "mismas", "tan", "tanto", "tanta", "tantos", "tantas", "tal", "tales", "cual", "cuales", "cuyo", "cuya", "cuyos", "cuyas", "quien", "quienes", "cuan", "cuanto", "cuanta", "cuantos", "cuantas", "como", "donde", "cuando", "que", "porque", "si", "aunque", "mientras", "cuando", "como", "donde", "siempre", "nunca", "jamás", "muy", "más", "menos", "tan", "tanto", "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", "siempre", "nunca", "jamás", "quizá", "acaso", "tal vez"}
325
+
326
+ # Filtrar palabras, eliminar stop words y palabras cortas
327
+ valid_words = [word for word in words if len(word) > 3 and word not in stop_words]
328
 
329
+ if not valid_words:
330
+ logger.warning("No se encontraron palabras clave válidas con método simple. Usando palabras clave predeterminadas.")
331
  return ["naturaleza", "ciudad", "paisaje"]
332
 
333
+ # Contar frecuencia y tomar las más comunes
334
+ word_counts = Counter(valid_words)
335
+ # Tomar hasta 5 palabras clave más comunes
336
+ top_keywords = [word.replace(" ", "+") for word, _ in word_counts.most_common(5)]
337
+
338
+ if not top_keywords:
339
+ logger.warning("El método simple no produjo keywords. Usando palabras clave predeterminadas.")
340
+ return ["naturaleza", "ciudad", "paisaje"]
341
+
342
  logger.info(f"Palabras clave finales: {top_keywords}")
343
  return top_keywords
344
 
345
+ # [FUNCIÓN CREAR_VIDEO CORREGIDA]
346
  def crear_video(prompt_type, input_text, musica_file=None):
347
  logger.info("="*80)
348
  logger.info(f"INICIANDO CREACIÓN DE VIDEO | Tipo: {prompt_type}")
349
  logger.debug(f"Input: '{input_text[:100]}...'")
350
 
 
351
  start_time = datetime.now()
352
+ temp_dir_intermediate = None # Directorio para archivos temporales intermedios
353
+ output_video_path = None # Path para el video final fuera del directorio intermedio
354
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  try:
356
+ # 1. Generar o usar guion
357
+ if prompt_type == "Generar Guion con IA":
358
+ logger.info("Generando guion con IA...")
359
+ guion = generate_script(input_text)
360
+ else:
361
+ logger.info("Usando guion proporcionado")
362
+ guion = input_text.strip() # Asegurar que el guion manual no tenga espacios al inicio/fin
363
+
364
+ logger.info(f"Guion final ({len(guion)} caracteres): '{guion[:100]}...'")
365
+
366
+ if not guion.strip():
367
+ logger.error("El guion resultante está vacío o solo contiene espacios.")
368
+ raise ValueError("El guion está vacío.")
369
+
370
+ # Crear directorio temporal para archivos intermedios (audio TTS, videos descargados)
371
+ temp_dir_intermediate = tempfile.mkdtemp(prefix="video_gen_intermediate_")
372
+ logger.info(f"Directorio temporal intermedio creado: {temp_dir_intermediate}")
373
+ temp_intermediate_files = [] # Lista para seguir los archivos que hay que limpiar
374
+
375
  # 2. Generar audio de voz
376
  logger.info("Generando audio de voz...")
377
+ voz_path = os.path.join(temp_dir_intermediate, "voz.mp3")
378
+ # No añadimos voz_path a temp_intermediate_files *antes* de la generación,
379
+ # solo si la generación es exitosa.
380
  if not asyncio.run(text_to_speech(guion, voz_path)):
381
+ logger.error("Fallo en generación de voz.")
382
+ raise ValueError("Error generando voz a partir del guion.")
383
+ temp_intermediate_files.append(voz_path) # Añadir para limpieza si la generación tuvo éxito
384
+
385
  audio_tts = AudioFileClip(voz_path)
386
  audio_duration = audio_tts.duration
387
  logger.info(f"Duración audio voz: {audio_duration:.2f} segundos")
388
+
389
+ if audio_duration < 1.0:
390
+ logger.warning(f"Duración del audio de voz ({audio_duration:.2f}s) es muy corta. El video podría ser muy breve o fallar.")
391
+ # Podrías añadir un mínimo o generar un guion/voz alternativo
392
+
393
  # 3. Extraer palabras clave
394
  logger.info("Extrayendo palabras clave...")
395
  try:
396
  keywords = extract_visual_keywords_from_script(guion)
397
  logger.info(f"Palabras clave identificadas: {keywords}")
398
  except Exception as e:
399
+ logger.error(f"Error extrayendo keywords: {str(e)}", exc_info=True)
400
+ keywords = ["naturaleza", "paisaje"] # Fallback si la extracción falla
401
+
402
+ if not keywords:
403
+ logger.warning("Lista de palabras clave vacía después de extracción y fallback.")
404
+ keywords = ["video", "background"] # Fallback final
405
+
406
  # 4. Buscar y descargar videos
407
  logger.info("Buscando videos en Pexels...")
408
  videos_data = []
409
+ # Buscar con todas las keywords, intentando obtener más resultados al principio
410
+ total_desired_videos = 10 # Intentar descargar hasta 10 videos en total
411
+ per_page_per_keyword = max(1, total_desired_videos // len(keywords)) # Distribuir la búsqueda
412
+
413
  for keyword in keywords:
414
+ if len(videos_data) >= total_desired_videos: break # Dejar de buscar si ya tenemos suficientes
415
  try:
416
+ videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=per_page_per_keyword)
417
  if videos:
418
  videos_data.extend(videos)
419
+ logger.info(f"Encontrados {len(videos)} videos para '{keyword}'. Total data: {len(videos_data)}")
420
  except Exception as e:
421
  logger.warning(f"Error buscando videos para '{keyword}': {str(e)}")
422
+
423
+ # Si no se encontraron suficientes videos con keywords específicas, intentar genéricas
424
+ if len(videos_data) < total_desired_videos / 2: # Si tenemos menos de la mitad de los deseados
425
+ logger.warning(f"Pocos videos encontrados ({len(videos_data)}). Intentando con palabras clave genéricas.")
426
+ generic_keywords = ["nature", "city", "background", "abstract"] # Palabras clave genéricas en inglés para Pexels
427
+ for keyword in generic_keywords:
428
+ if len(videos_data) >= total_desired_videos: break
429
+ try:
430
+ # Buscar menos por cada genérica para no saturar
431
+ videos = buscar_videos_pexels(keyword, PEXELS_API_KEY, per_page=2)
432
+ if videos:
433
+ videos_data.extend(videos)
434
+ logger.info(f"Encontrados {len(videos)} videos para '{keyword}' (genérico). Total data: {len(videos_data)}")
435
+ except Exception as e:
436
+ logger.warning(f"Error buscando videos genéricos para '{keyword}': {str(e)}")
437
+
438
+
439
  if not videos_data:
440
+ logger.error("No se encontraron videos en Pexels para ninguna palabra clave.")
441
+ raise ValueError("No se encontraron videos adecuados en Pexels.")
442
 
443
  video_paths = []
444
+ logger.info(f"Intentando descargar {len(videos_data)} videos encontrados...")
445
  for video in videos_data:
446
  if 'video_files' not in video or not video['video_files']:
447
+ logger.debug(f"Saltando video sin archivos de video: {video.get('id')}")
448
  continue
449
 
450
  try:
451
+ # Encontrar la mejor calidad disponible con un enlace de descarga
452
+ best_quality = None
453
+ for vf in sorted(video['video_files'], key=lambda x: x.get('width', 0) * x.get('height', 0), reverse=True):
454
+ if 'link' in vf:
455
+ best_quality = vf
456
+ break
457
+
458
+ if best_quality and 'link' in best_quality:
459
+ path = download_video_file(best_quality['link'], temp_dir_intermediate)
460
  if path:
461
  video_paths.append(path)
462
+ temp_intermediate_files.append(path) # Añadir para limpieza
463
+ logger.info(f"Video descargado OK desde {best_quality['link'][:50]}...")
464
+ else:
465
+ logger.warning(f"No se pudo descargar video desde {best_quality['link'][:50]}...")
466
+ else:
467
+ logger.warning(f"No se encontró enlace de descarga válido para video {video.get('id')}.")
468
+
469
  except Exception as e:
470
+ logger.warning(f"Error procesando/descargando video {video.get('id')}: {str(e)}")
471
+
472
+ logger.info(f"Descargados {len(video_paths)} archivos de video utilizables.")
473
  if not video_paths:
474
+ logger.error("No se pudo descargar ningún archivo de video utilizable.")
475
+ raise ValueError("No se pudo descargar ningún video utilizable de Pexels.")
476
 
477
+ # 5. Procesar y concatenar clips de video
478
+ logger.info("Procesando y concatenando videos descargados...")
479
  clips = []
480
  current_duration = 0
481
+ min_clip_duration = 1.0 # Duración mínima para un clip
482
+ max_clip_segment = 8.0 # Segmento máximo a tomar de un clip fuente
483
+
484
+ # Ordenar clips descargados por tamaño o duración si se desea, o usar el orden de descarga
485
+ # Por ahora, usamos el orden de descarga
486
+
487
  for path in video_paths:
488
+ # Dejar de añadir clips si ya tenemos suficiente duración o estamos cerca
489
+ # Añadimos un buffer para no cortar bruscamente el último clip
490
+ if current_duration >= audio_duration + max_clip_segment:
491
  break
492
+
493
  try:
494
+ logger.debug(f"Abriendo clip: {path}")
495
  clip = VideoFileClip(path)
 
496
 
497
+ # Calcular duración útil: min(duración_restante_necesaria, duración_total_clip, segmento_máximo)
498
+ remaining_needed = audio_duration - current_duration
499
+ usable_duration = min(clip.duration, max_clip_segment)
500
+
501
+ # Si necesitamos más duración, tomamos hasta el final del clip o el max_clip_segment
502
+ if remaining_needed > 0:
503
+ usable_duration = min(usable_duration, remaining_needed + min_clip_duration) # Añadir un poco extra si el último clip nos deja justos
504
+ usable_duration = max(min_clip_duration, usable_duration) # Asegurar duración mínima si es posible
505
+
506
+ if usable_duration > min_clip_duration:
507
+ # Asegurar que no excedemos la duración del clip fuente
508
+ segment_duration = min(usable_duration, clip.duration)
509
+ if segment_duration > min_clip_duration:
510
+ clips.append(clip.subclip(0, segment_duration))
511
+ current_duration += segment_duration
512
+ logger.debug(f"Clip añadido: {segment_duration:.1f}s (total video: {current_duration:.1f}/{audio_duration:.1f}s)")
513
+ else:
514
+ logger.debug(f"Clip {path} es demasiado corto ({clip.duration:.1f}s) para añadir un segmento útil.")
515
+ else:
516
+ logger.debug(f"Clip {path} demasiado corto ({clip.duration:.1f}s) o no necesita más duración.")
517
+
518
+ # Cerrar clip para liberar recursos
519
+ clip.close()
520
+
521
  except Exception as e:
522
+ logger.warning(f"Error procesando video clip {path}: {str(e)}", exc_info=True)
523
+ # Continuar con el siguiente clip si hay un error con este
524
+ continue
525
+
526
  if not clips:
527
+ logger.error("No hay clips de video válidos para crear la secuencia.")
528
+ raise ValueError("No hay clips de video válidos para crear el video.")
529
 
530
+ logger.info(f"Concatenando {len(clips)} clips de video.")
531
  video_base = concatenate_videoclips(clips, method="compose")
532
+ logger.info(f"Duración base del video concatenado: {video_base.duration:.2f}s")
533
 
534
+ # Asegurar que el video base tenga al menos la duración del audio
535
  if video_base.duration < audio_duration:
536
+ # Calcular cuántas veces se necesita repetir el video base o parte de él
537
+ # Un método simple es repetir el video base completo
538
  num_repeats = math.ceil(audio_duration / video_base.duration)
539
+ logger.info(f"Repitiendo video base ({video_base.duration:.2f}s) {num_repeats} veces para alcanzar {audio_duration:.2f}s.")
540
+ repeated_clips = [video_base] * num_repeats
541
+ video_base = concatenate_videoclips(repeated_clips, method="compose").subclip(0, audio_duration)
542
+ logger.info(f"Duración del video base ajustada: {video_base.duration:.2f}s")
543
+
544
+ # Si el video base es más largo que el audio (puede pasar si el último clip añadido fue largo)
545
+ # Recortarlo exactamente a la duración del audio
546
+ if video_base.duration > audio_duration:
547
+ logger.info(f"Recortando video base ({video_base.duration:.2f}s) a la duración del audio ({audio_duration:.2f}s).")
548
+ video_base = video_base.subclip(0, audio_duration)
549
+ logger.info(f"Duración final del video base: {video_base.duration:.2f}s")
550
+
551
+
552
  # 6. Manejar música de fondo
553
  logger.info("Procesando audio...")
554
+ final_audio = audio_tts # El audio base es la voz TTS
555
 
556
  if musica_file:
557
  try:
558
+ # Copiar archivo de música a un lugar temporal seguro
559
+ music_path = os.path.join(temp_dir_intermediate, "musica_bg.mp3")
560
  shutil.copyfile(musica_file, music_path)
561
+ temp_intermediate_files.append(music_path) # Añadir para limpieza
562
+ logger.info(f"Música de fondo copiada a: {music_path}")
563
 
564
  musica_audio = AudioFileClip(music_path)
565
  logger.debug(f"Duración música original: {musica_audio.duration:.2f}s")
566
 
567
+ # Ajustar duración de la música al video final (que ya tiene la duración del audio TTS)
568
+ musica_audio = loop_audio_to_length(musica_audio, video_base.duration)
569
+ logger.debug(f"Música ajustada a duración del video: {musica_audio.duration:.2f}s")
570
 
571
+ # Mezclar voz y música. Ajusta los volúmenes según prefieras.
572
  final_audio = CompositeAudioClip([
573
+ musica_audio.volumex(0.2), # Volumen bajo para la música
574
+ audio_tts.volumex(1.0) # Volumen normal para la voz
575
  ])
576
+ logger.info("Mezcla de audio completada (voz + música).")
577
  except Exception as e:
578
+ logger.warning(f"Error procesando música de fondo: {str(e)}", exc_info=True)
579
+ # Si falla la música, simplemente usamos el audio TTS
580
+ final_audio = audio_tts
581
+ logger.warning("Usando solo audio de voz debido a un error con la música.")
582
+
583
+ # Asegurar que el audio final tenga exactamente la misma duración que el video base
584
+ if final_audio.duration > video_base.duration:
585
+ final_audio = final_audio.subclip(0, video_base.duration)
586
+ elif final_audio.duration < video_base.duration:
587
+ # Esto no debería pasar si loop_audio_to_length funciona, pero como seguridad
588
+ logger.warning("La duración del audio final es menor que la del video base. Intentando ajustar.")
589
+ # Podría re-ajustar o logear error más severo
590
+ # Para evitar errores en write_videofile, es mejor que el audio no sea más corto
591
+ # Si es más corto, moviepy suele extender el último frame de audio, lo cual es feo pero funciona.
592
+ # No intentaremos extenderlo aquí automáticamente para evitar complejidad, MoviePy lo manejará.
593
+ pass # MoviePy manejará la diferencia de duración
594
+
595
  # 7. Crear video final
596
  logger.info("Renderizando video final...")
597
  video_final = video_base.set_audio(final_audio)
 
598
 
599
+ # Gradio maneja directorios temporales para los outputs.
600
+ # Podemos escribir directamente en un archivo temporal dentro de la carpeta temporal de Gradio.
601
+ # Si no estamos en un entorno Gradio, podríamos usar un tempfile normal.
602
+ # Por simplicidad y compatibilidad con Gradio, devolveremos el path absoluto.
603
+ # El directorio temporal creado por tempfile.mkdtemp() EXISTIRÁ cuando la función retorne
604
+ # porque hemos eliminado la línea que lo borraba. Gradio debería poder leerlo.
605
+ # Si queremos un archivo temporal más "formal" de Gradio, necesitaríamos que Gradio
606
+ # nos pasara un path temporal. La forma actual de retornar el path debería funcionar
607
+ # ahora que no borramos el directorio.
608
+
609
+ # Asegurar que el directorio para el output existe si no es temp_dir_intermediate
610
+ # output_filename = f"final_video_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4"
611
+ # # Si queremos que el archivo no esté en temp_dir_intermediate, podríamos usar otro temp_dir
612
+ # # o un path fijo para prueba. Para Gradio, devolver el path en temp_dir_intermediate
613
+ # # FUNCIONARÁ si temp_dir_intermediate NO SE BORRA.
614
+ # output_path = os.path.join(temp_dir_intermediate, output_filename)
615
+
616
+ # Dejar el nombre y path original dentro del temp_dir_intermediate,
617
+ # pero ASEGURARSE de que el rmtree del finally está COMENTADO/ELIMINADO.
618
+ output_filename = "final_video.mp4"
619
+ output_path = os.path.join(temp_dir_intermediate, output_filename)
620
+ logger.info(f"Escribiendo video final a: {output_path}")
621
+
622
  video_final.write_videofile(
623
  output_path,
624
  fps=24,
625
+ threads=4, # Usa un número razonable de hilos
626
+ codec="libx264", # Codec estándar compatible
627
+ audio_codec="aac", # Codec de audio estándar
628
+ preset="medium", # Balance entre velocidad y tamaño/calidad
629
+ # CORRECCIÓN: Mostrar log de MoviePy para debugging
630
+ logger='bar' # Muestra una barra de progreso y mensajes de FFmpeg
631
+ # logger='log' # Muestra mensajes detallados de FFmpeg
632
  )
633
 
634
  total_time = (datetime.now() - start_time).total_seconds()
635
+ logger.info(f"PROCESO DE VIDEO FINALIZADO | Output: {output_path} | Tiempo total: {total_time:.2f}s")
 
636
 
637
+ # Cerrar clips para liberar recursos
638
+ video_base.close()
639
+ audio_tts.close()
640
+ if musica_file: musica_audio.close()
641
+ video_final.close()
642
+
643
+ return output_path # Retornar el path del archivo generado
644
+
645
+ except ValueError as ve:
646
+ logger.error(f"ERROR CONTROLADO en crear_video: {str(ve)}")
647
+ # Asegurar que se cierran los clips abiertos antes de relanzar
648
+ # (Esto es mejor hacerlo en el finally, pero aquí es una capa extra)
649
+ try:
650
+ if 'video_base' in locals() and video_base is not None: video_base.close()
651
+ if 'audio_tts' in locals() and audio_tts is not None: audio_tts.close()
652
+ if 'musica_audio' in locals() and musica_audio is not None: musica_audio.close()
653
+ if 'video_final' in locals() and video_final is not None: video_final.close()
654
+ except: pass # Ignore errors during cleanup before re-raise
655
+ raise ve # Rellenar la excepción para que la atrape run_app
656
+
657
  except Exception as e:
658
+ logger.critical(f"ERROR CRÍTICO NO CONTROLADO en crear_video: {str(e)}", exc_info=True)
659
+ # Asegurar que se cierran los clips abiertos antes de relanzar
 
 
 
 
 
 
 
 
 
660
  try:
661
+ if 'video_base' in locals() and video_base is not None: video_base.close()
662
+ if 'audio_tts' in locals() and audio_tts is not None: audio_tts.close()
663
+ if 'musica_audio' in locals() and musica_audio is not None: musica_audio.close()
664
+ if 'video_final' in locals() and video_final is not None: video_final.close()
665
+ except: pass # Ignore errors during cleanup before re-raise
666
+ raise e # Rellenar la excepción
667
+
668
+ finally:
669
+ logger.info("Iniciando limpieza de archivos temporales intermedios...")
670
+ # Limpiar solo los archivos listados como temporales intermedios
671
+ if temp_dir_intermediate and os.path.exists(temp_dir_intermediate):
672
+ # Asegurarse de que el archivo de video final *no* sea borrado si está en esta lista por error
673
+ final_output_in_temp = os.path.join(temp_dir_intermediate, "final_video.mp4")
674
+
675
+ for path in temp_intermediate_files:
676
+ try:
677
+ if os.path.isfile(path) and path != final_output_in_temp: # No borrar el archivo final
678
+ logger.debug(f"Eliminando archivo temporal: {path}")
679
+ os.remove(path)
680
+ elif os.path.isdir(path): # Limpieza defensiva, aunque no deberíamos tener directorios aquí
681
+ logger.warning(f"Intentando eliminar directorio listado como archivo temporal: {path}")
682
+ shutil.rmtree(path, ignore_errors=True)
683
+ except Exception as e:
684
+ logger.warning(f"No se pudo eliminar archivo temporal {path}: {str(e)}")
685
+
686
+ # CORRECCIÓN CRÍTICA: NO eliminar el directorio temporal completo con rmtree.
687
+ # Este directorio contiene el video final que Gradio necesita leer.
688
+ # El sistema operativo se encargará de limpiar los directorios temporales abandonados
689
+ # con el tiempo, o puedes implementar una limpieza más sofisticada si es necesario
690
+ # para archivos *muy* viejos, pero NO lo hagas inmediatamente después de crear el archivo.
691
+ # shutil.rmtree(temp_dir_intermediate, ignore_errors=True) # COMENTADO/ELIMINADO intencionadamente
692
+ logger.info(f"Directorio temporal intermedio {temp_dir_intermediate} persistirá para que Gradio lea el video final.")
693
+
694
 
695
  # [FUNCIÓN RUN_APP ORIGINAL CON LOGS]
696
  def run_app(prompt_type, prompt_ia, prompt_manual, musica_file):
697
  logger.info("="*80)
698
  logger.info("SOLICITUD RECIBIDA EN INTERFAZ")
 
699
 
700
+ input_text = prompt_ia if prompt_type == "Generar Guion con IA" else prompt_manual
 
 
701
 
702
+ if not input_text or not input_text.strip():
703
+ logger.warning("Texto de entrada vacío.")
704
+ # Asegurarse de devolver None para el video y un mensaje de error
705
+ return None, gr.update(value="⚠️ Por favor, ingresa texto para el guion o el tema.")
706
+
707
+ # Gradio actualiza la interfaz, no necesitamos un primer return para "Procesando..." aquí,
708
+ # el .then() encadenado en la interfaz ya maneja eso.
709
+ logger.info(f"Tipo de entrada: {prompt_type}")
710
+ logger.debug(f"Texto de entrada: '{input_text[:100]}...'")
711
+ if musica_file:
712
+ logger.info(f"Archivo de música recibido: {musica_file}")
713
+ else:
714
+ logger.info("No se proporcionó archivo de música.")
715
+
716
  try:
717
+ logger.info("Llamando a crear_video...")
718
  video_path = crear_video(prompt_type, input_text, musica_file)
719
+
720
+ if video_path and os.path.exists(video_path):
721
+ logger.info(f"Función crear_video retornó path: {video_path}")
722
+ logger.info(f"Tamaño del archivo de video retornado: {os.path.getsize(video_path)} bytes")
723
+ return video_path, gr.update(value="✅ Video generado exitosamente.", interactive=False)
724
+ else:
725
+ logger.error(f"crear_video no retornó un path válido o el archivo no existe: {video_path}")
726
+ return None, gr.update(value="❌ Error: La generación del video falló o el archivo no se creó correctamente.", interactive=False)
727
+
728
  except ValueError as ve:
729
+ logger.warning(f"Error de validación durante la creación del video: {str(ve)}")
730
+ return None, gr.update(value=f"⚠️ Error de validación: {str(ve)}", interactive=False)
731
  except Exception as e:
732
+ logger.critical(f"Error crítico durante la creación del video: {str(e)}", exc_info=True)
733
+ return None, gr.update(value=f"❌ Error inesperado: {str(e)}", interactive=False)
734
+ finally:
735
+ logger.info("Fin del handler run_app.")
736
+
737
 
738
  # [INTERFAZ DE GRADIO ORIGINAL COMPLETA]
739
  with gr.Blocks(title="Generador de Videos con IA", theme=gr.themes.Soft(), css="""
 
742
  """) as app:
743
 
744
  gr.Markdown("# 🎬 Generador Automático de Videos con IA")
745
+ gr.Markdown("Genera videos cortos a partir de un tema o guion, usando imágenes de archivo de Pexels y voz generada.")
746
 
747
  with gr.Row():
748
  with gr.Column():
749
  prompt_type = gr.Radio(
750
+ ["Generar Guion con IA", "Usar Mi Guion"],
751
  label="Método de Entrada",
752
+ value="Generar Guion con IA" # Valor por defecto
753
  )
754
 
755
  with gr.Column(visible=True) as ia_guion_column:
756
  prompt_ia = gr.Textbox(
757
  label="Tema para IA",
758
  lines=2,
759
+ placeholder="Ej: Un paisaje natural con montañas y ríos al amanecer, mostrando la belleza de la naturaleza...",
760
+ max_lines=4,
761
+ value="" # Inicializar vacío
762
  )
763
 
764
  with gr.Column(visible=False) as manual_guion_column:
765
  prompt_manual = gr.Textbox(
766
  label="Tu Guion Completo",
767
  lines=5,
768
+ 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!",
769
+ max_lines=10,
770
+ value="" # Inicializar vacío
771
  )
772
 
773
  musica_input = gr.Audio(
774
  label="Música de fondo (opcional)",
775
  type="filepath",
776
+ interactive=True,
777
+ value=None # Inicializar vacío
778
  )
779
 
780
  generate_btn = gr.Button("✨ Generar Video", variant="primary")
 
782
  with gr.Column():
783
  video_output = gr.Video(
784
  label="Video Generado",
785
+ interactive=False, # No interactivo para el usuario
786
+ height=400 # Altura fija
787
  )
788
  status_output = gr.Textbox(
789
  label="Estado",
790
+ interactive=False, # Solo lectura
791
+ show_label=False, # No mostrar la etiqueta "Estado"
792
+ placeholder="Esperando acción...",
793
+ value="Esperando entrada..." # Estado inicial
794
  )
795
 
796
+ # Lógica para mostrar/ocultar campos de texto según el tipo de prompt
797
  prompt_type.change(
798
  lambda x: (gr.update(visible=x == "Generar Guion con IA"),
799
  gr.update(visible=x == "Usar Mi Guion")),
 
801
  outputs=[ia_guion_column, manual_guion_column]
802
  )
803
 
804
+ # Lógica para el botón de generar video
805
+ # Primero, actualizar el estado a "Procesando..."
806
+ # Luego, llamar a la función run_app
807
  generate_btn.click(
808
+ # La primera acción (sincrona) actualiza el estado y limpia el video anterior
809
+ lambda: (None, gr.update(value="⏳ Procesando... Esto puede tomar 2-5 minutos dependiendo de la longitud y recursos.", interactive=False)),
810
  outputs=[video_output, status_output],
811
+ queue=True, # Poner en cola si hay varias peticiones
812
+ # preprocess=False, # No pre-procesar para esta primera acción
813
  ).then(
814
+ # La segunda acción (asincrona) ejecuta la función principal de generación
815
  run_app,
816
  inputs=[prompt_type, prompt_ia, prompt_manual, musica_input],
817
  outputs=[video_output, status_output]
 
819
 
820
  gr.Markdown("### Instrucciones:")
821
  gr.Markdown("""
822
+ 1. **Clave API de Pexels:** Asegúrate de haber configurado la variable de entorno `PEXELS_API_KEY` con tu clave.
823
+ 2. **Selecciona el tipo de entrada**:
824
+ - "Generar Guion con IA": Describe brevemente un tema (ej. "La belleza de las montañas"). La IA generará un guion corto.
825
+ - "Usar Mi Guion": Escribe el guion completo que quieres para el video.
826
+ 3. **Sube música** (opcional): Selecciona un archivo de audio (MP3, WAV, etc.) para usar como música de fondo.
827
+ 4. **Haz clic en "✨ Generar Video"**.
828
+ 5. Espera a que se procese el video. El tiempo de espera puede variar. Verás el estado en el cuadro de texto.
829
+ 6. Si hay errores, revisa el log `video_generator_full.log` para más detalles.
830
  """)
831
+ gr.Markdown("---")
832
+ gr.Markdown("Desarrollado por [Tu Nombre/Empresa/Alias - Opcional]") # Añade tu nombre o un alias si quieres
833
 
834
  if __name__ == "__main__":
835
+ # Verificar si FFmpeg está instalado (MoviePy lo requiere)
836
+ # Puedes añadir esta verificación aquí
837
+ logger.info("Verificando dependencias críticas...")
838
+ try:
839
+ # moviepy intenta encontrar ffmpeg, si falla, lanzar error
840
+ # from moviepy.config import get_setting
841
+ # ffmpeg_path = get_setting("FFMPEG_BINARY")
842
+ # if not os.path.exists(ffmpeg_path):
843
+ # logger.critical(f"FFmpeg no encontrado en {ffmpeg_path}. Instálalo y/o configura la variable FFMPEG_BINARY.")
844
+ # # raise RuntimeError("FFmpeg no está instalado o configurado correctamente.")
845
+ # else:
846
+ # logger.info(f"FFmpeg encontrado en {ffmpeg_path}")
847
+
848
+ # Una forma más simple es intentar importar algo que use FFmpeg
849
+ try:
850
+ from moviepy.editor import VideoFileClip
851
+ # No cargamos un archivo real, solo verificamos que el import no falle por FFmpeg
852
+ logger.info("MoviePy importado correctamente. FFmpeg parece accesible.")
853
+ except Exception as e:
854
+ logger.critical(f"Fallo al importar MoviePy, a menudo indica problemas con FFmpeg. Asegúrate de tenerlo instalado y en el PATH. Error: {e}")
855
+ # raise RuntimeError("MoviePy/FFmpeg dependency issue.")
856
+
857
+ except Exception as e:
858
+ logger.warning(f"No se pudo verificar FFmpeg automáticamente: {e}") # Esto no debería detener el script, solo advertir
859
+
860
  logger.info("Iniciando aplicación Gradio...")
861
  try:
862
+ # Usa share=True para obtener un enlace público si lo necesitas para probar fuera de localhost
863
+ # No uses share=True en producción sin medidas de seguridad adecuadas
864
+ app.launch(server_name="0.0.0.0", server_port=7860, share=False)
865
  except Exception as e:
866
  logger.critical(f"No se pudo iniciar la app: {str(e)}", exc_info=True)
867
+ raise # Relanzar la excepción para ver el traceback completo si falla al iniciar