gnosticdev commited on
Commit
5f972c1
·
verified ·
1 Parent(s): be5058a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +162 -92
app.py CHANGED
@@ -1,7 +1,3 @@
1
- import os, re, math, uuid, time, shutil, logging, tempfile, threading, requests, asyncio, numpy as np, json
2
- from datetime import datetime, timedelta
3
- from collections import Counter
4
-
5
  import gradio as gr
6
  import torch
7
  from huggingface_hub import hf_hub_download
@@ -10,9 +6,29 @@ import soundfile as sf
10
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
11
  from keybert import KeyBERT
12
  from moviepy.editor import (
13
- VideoFileClip, AudioFileClip, concatenate_videoclips, concatenate_audioclips,
14
- CompositeAudioClip, AudioClip, TextClip, CompositeVideoClip, VideoClip
 
 
 
 
 
 
 
15
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  # ------------------- CÓDIGO DEL MOTOR TOUCANTTS (Integrado) -------------------
18
  # Este bloque contiene las funciones y clases extraídas para que el TTS funcione sin archivos externos.
@@ -20,9 +36,11 @@ from moviepy.editor import (
20
  # --- Contenido de Utility/utils.py ---
21
  def float2pcm(sig, dtype='int16'):
22
  sig = np.asarray(sig)
23
- if sig.dtype.kind != 'f': raise TypeError("'sig' must be a float array")
 
24
  dtype = np.dtype(dtype)
25
- if dtype.kind not in 'iu': raise TypeError("'dtype' must be an integer type")
 
26
  i = np.iinfo(dtype)
27
  abs_max = 2 ** (i.bits - 1)
28
  offset = i.min + abs_max
@@ -40,26 +58,20 @@ def load_json_from_path(path):
40
  class ToucanTTSInterface:
41
  def __init__(self, gpu_id="cpu"):
42
  self.device = torch.device("cpu") if gpu_id == "cpu" else torch.device("cuda")
43
-
44
  tts_model_path = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="best.pt")
45
  vocoder_model_path = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="vocoder.pt")
46
-
47
  # Importamos la clase aquí para evitar problemas de dependencias circulares
48
  from TrainingInterfaces.Text_to_Spectrogram.ToucanTTS.ToucanTTS import ToucanTTS as ToucanTTS_Model
49
-
50
  self.tts_model = ToucanTTS_Model()
51
  self.tts_model.load_state_dict(torch.load(tts_model_path, map_location=self.device)["model"])
52
  self.vocoder_model = torch.jit.load(vocoder_model_path).to(self.device).eval()
53
-
54
  path_to_iso_list = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="iso_to_id.json")
55
  self.iso_to_id = load_json_from_path(path_to_iso_list)
56
-
57
  self.tts_model.to(self.device)
58
 
59
  def read(self, text, language="spa", accent="spa"):
60
  with torch.inference_mode():
61
  style_embedding = self.tts_model.style_embedding_function(torch.randn([1, 1, 192]).to(self.device)).squeeze()
62
-
63
  output_wave, output_sr, _ = self.tts_model.read(
64
  text=text,
65
  style_embedding=style_embedding,
@@ -68,7 +80,7 @@ class ToucanTTSInterface:
68
  vocoder=self.vocoder_model,
69
  device=self.device
70
  )
71
- return output_sr, output_wave.cpu().numpy()
72
 
73
  # ------------------- Configuración & Globals -------------------
74
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
@@ -89,7 +101,8 @@ def get_tokenizer():
89
  if tokenizer is None:
90
  logger.info("Cargando tokenizer (primera vez)...")
91
  tokenizer = GPT2Tokenizer.from_pretrained("datificate/gpt2-small-spanish")
92
- if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token
 
93
  return tokenizer
94
 
95
  def get_gpt2_model():
@@ -106,20 +119,11 @@ def get_kw_model():
106
  kw_model = KeyBERT("paraphrase-multilingual-MiniLM-L12-v2")
107
  return kw_model
108
 
109
- class DummyTTS:
110
- def read(self, text, language="spa", accent="spa"):
111
- sr = 22050
112
- dur = max(2.0, min(20.0, len(text) / 10)) # 2–20s según el texto
113
- t = np.linspace(0, dur, int(sr * dur), False)
114
- freq = 200.0
115
- wav = 0.2 * np.sin(2 * np.pi * freq * t).astype(np.float32)
116
- return sr, wav
117
-
118
  def get_tts_interface():
119
- global tts_interface
120
- if tts_interface is None:
121
- tts_interface = DummyTTS()
122
- return tts_interface
123
 
124
  # ------------------- Funciones del Pipeline de Vídeo -------------------
125
  def update_task_progress(task_id, message):
@@ -133,9 +137,15 @@ def gpt2_script(prompt: str) -> str:
133
  instruction = f"Escribe un guion corto y coherente sobre: {prompt}"
134
  inputs = local_tokenizer(instruction, return_tensors="pt", truncation=True, max_length=512)
135
  outputs = local_gpt2_model.generate(
136
- **inputs, max_length=160 + inputs["input_ids"].shape[1], do_sample=True,
137
- top_p=0.9, top_k=40, temperature=0.7, no_repeat_ngram_size=3,
138
- pad_token_id=local_tokenizer.pad_token_id, eos_token_id=local_tokenizer.eos_token_id,
 
 
 
 
 
 
139
  )
140
  text = local_tokenizer.decode(outputs[0], skip_special_tokens=True)
141
  return text.split("sobre:")[-1].strip()
@@ -155,8 +165,12 @@ def keywords(text: str) -> list[str]:
155
  return [k.replace(" ", "+") for k, _ in kws if k] or ["naturaleza"]
156
 
157
  def pexels_search(query: str, count: int) -> list[dict]:
158
- res = requests.get("https://api.pexels.com/videos/search", headers={"Authorization": PEXELS_API_KEY},
159
- params={"query": query, "per_page": count, "orientation": "landscape"}, timeout=20)
 
 
 
 
160
  res.raise_for_status()
161
  return res.json().get("videos", [])
162
 
@@ -167,31 +181,46 @@ def download_file(url: str, folder: str) -> str | None:
167
  with requests.get(url, stream=True, timeout=60) as r:
168
  r.raise_for_status()
169
  with open(path, "wb") as f:
170
- for chunk in r.iter_content(1024 * 1024): f.write(chunk)
 
171
  return path if os.path.exists(path) and os.path.getsize(path) > 1000 else None
172
  except Exception as e:
173
  logger.error(f"Fallo al descargar {url}: {e}")
174
  return None
175
 
176
  def loop_audio(audio_clip: AudioFileClip, duration: float) -> AudioFileClip:
177
- if audio_clip.duration >= duration: return audio_clip.subclip(0, duration)
 
178
  loops = math.ceil(duration / audio_clip.duration)
179
  return concatenate_audioclips([audio_clip] * loops).subclip(0, duration)
180
 
181
  def make_subtitle_clips(script: str, video_w: int, video_h: int, duration: float):
182
  sentences = [s.strip() for s in re.split(r"[.!?¿¡]", script) if s.strip()]
183
- if not sentences: return []
 
184
  total_words = sum(len(s.split()) for s in sentences) or 1
185
  time_per_word = duration / total_words
186
  clips, current_time = [], 0.0
187
  for sentence in sentences:
188
  num_words = len(sentence.split())
189
  sentence_duration = num_words * time_per_word
190
- if sentence_duration < 0.1: continue
191
- txt_clip = (TextClip(sentence, fontsize=int(video_h * 0.05), color="white",
192
- stroke_color="black", stroke_width=1.5, method="caption",
193
- size=(int(video_w * 0.9), None), font="Arial-Bold")
194
- .set_start(current_time).set_duration(sentence_duration).set_position(("center", "bottom")))
 
 
 
 
 
 
 
 
 
 
 
 
195
  clips.append(txt_clip)
196
  current_time += sentence_duration
197
  return clips
@@ -208,61 +237,78 @@ def build_video(script_text: str, generate_script_flag: bool, music_path: str |
208
  try:
209
  update_task_progress(task_id, "Paso 1/7: Generando guion...")
210
  script = gpt2_script(script_text) if generate_script_flag else script_text.strip()
211
-
212
- update_task_progress(task_id, f"Paso 2/7: Creando audio con ToucanTTS...")
213
  voice_path = os.path.join(tmp_dir, "voice.wav")
214
  toucan_tts_synth(script, voice_path)
215
  voice_clip = AudioFileClip(voice_path)
216
  video_duration = voice_clip.duration
217
- if video_duration < 1: raise ValueError("El audio generado es demasiado corto.")
218
-
219
  update_task_progress(task_id, "Paso 3/7: Buscando clips en Pexels...")
220
  video_paths = []
221
  kws = keywords(script)
222
  for i, kw in enumerate(kws):
223
  update_task_progress(task_id, f"Paso 3/7: Buscando... (keyword {i+1}/{len(kws)}: '{kw}')")
224
- if len(video_paths) >= 8: break
 
225
  for video_data in pexels_search(kw, 2):
226
- best_file = max(video_data.get("video_files", []), key=lambda f: f.get("width", 0))
 
 
 
227
  if best_file:
228
  path = download_file(best_file.get('link'), tmp_dir)
229
- if path: video_paths.append(path)
230
- if len(video_paths) >= 8: break
231
- if not video_paths: raise RuntimeError("No se encontraron vídeos en Pexels.")
232
-
 
 
233
  update_task_progress(task_id, f"Paso 4/7: Ensamblando {len(video_paths)} clips...")
234
- segments = [VideoFileClip(p).subclip(0, min(8, VideoFileClip(p).duration)) for p in video_paths]
 
 
 
235
  base_video = concatenate_videoclips(segments, method="chain")
236
  if base_video.duration < video_duration:
237
  base_video = concatenate_videoclips([base_video] * math.ceil(video_duration / base_video.duration))
238
  base_video = base_video.subclip(0, video_duration)
239
-
240
  update_task_progress(task_id, "Paso 5/7: Componiendo audio final...")
241
  if music_path:
242
  music_clip = loop_audio(AudioFileClip(music_path), video_duration).volumex(0.20)
243
  final_audio = CompositeAudioClip([music_clip, voice_clip])
244
- else: final_audio = voice_clip
245
-
246
  update_task_progress(task_id, "Paso 6/7: Añadiendo subtítulos y efectos...")
247
  subtitles = make_subtitle_clips(script, base_video.w, base_video.h, video_duration)
248
  grain_effect = make_grain_clip(base_video.size, video_duration)
249
-
250
  update_task_progress(task_id, "Paso 7/7: Renderizando vídeo final (esto puede tardar)...")
251
  final_video = CompositeVideoClip([base_video, grain_effect, *subtitles]).set_audio(final_audio)
252
  output_path = os.path.join(tmp_dir, "final_video.mp4")
253
- final_video.write_videofile(output_path, fps=24, codec="libx264", audio_codec="aac", threads=2, logger=None)
254
-
 
 
 
 
 
 
255
  return output_path
256
  finally:
257
- if 'voice_clip' in locals(): voice_clip.close()
258
- if 'music_clip' in locals(): music_clip.close()
259
- if 'base_video' in locals(): base_video.close()
260
- if 'final_video' in locals(): final_video.close()
 
 
 
 
261
  if 'segments' in locals():
262
- for seg in segments: seg.close()
 
263
 
264
  def worker(task_id: str, mode: str, topic: str, user_script: str, music: str | None):
265
- # Carga del motor TTS aquí, para que ocurra dentro del hilo de trabajo y no bloquee el arranque
266
  global tts_interface
267
  if tts_interface is None:
268
  update_task_progress(task_id, "Cargando motor de voz ToucanTTS (primera vez, puede tardar)...")
@@ -275,23 +321,21 @@ def worker(task_id: str, mode: str, topic: str, user_script: str, music: str | N
275
  # Para una solución real, el código de ToucanTTS tendría que estar en el path.
276
  # get_tts_interface()
277
  except Exception as e:
278
- TASKS[task_id].update({"status": "error", "error": f"Fallo al cargar el motor TTS: {e}"})
279
- return
280
-
281
  try:
282
  text = topic if mode == "Generar Guion con IA" else user_script
283
- result_tmp_path = build_video(text, mode == "Generar Guion con IA", music, task_id)
284
- final_path = os.path.join(RESULTS_DIR, f"{task_id}.mp4")
285
- shutil.copy2(result_tmp_path, final_path)
286
- TASKS[task_id].update({"status": "done", "result": final_path})
287
- shutil.rmtree(os.path.dirname(result_tmp_path))
288
-
289
-
290
  except Exception as e:
291
  logger.error(f"Error en el worker para la tarea {task_id}: {e}", exc_info=True)
292
  TASKS[task_id].update({"status": "error", "error": str(e)})
293
 
294
-
295
  def janitor_thread():
296
  while True:
297
  time.sleep(3600)
@@ -314,45 +358,71 @@ def generate_and_monitor(mode, topic, user_script, music):
314
  if not content.strip():
315
  yield "Por favor, ingresa un tema o guion.", None, None
316
  return
317
-
318
  task_id = uuid.uuid4().hex[:8]
319
- TASKS[task_id] = {"status": "processing", "progress_log": "Iniciando tarea...", "timestamp": datetime.utcnow()}
320
-
321
- worker_thread = threading.Thread(target=worker, args=(task_id, mode, topic, user_script, music), daemon=True)
 
 
 
 
 
 
 
322
  worker_thread.start()
323
-
324
  while TASKS[task_id]["status"] == "processing":
325
  yield TASKS[task_id]['progress_log'], None, None
326
  time.sleep(1)
327
-
328
  if TASKS[task_id]["status"] == "error":
329
  yield f"❌ Error: {TASKS[task_id]['error']}", None, None
330
  elif TASKS[task_id]["status"] == "done":
331
  yield "✅ ¡Vídeo completado!", TASKS[task_id]['result'], TASKS[task_id]['result']
332
 
 
333
  with gr.Blocks(title="Generador de Vídeos IA", theme=gr.themes.Soft()) as demo:
334
  gr.Markdown("# 🎬 Generador de Vídeos con IA")
335
  gr.Markdown("Crea vídeos a partir de texto con voz, música y efectos visuales. El progreso se mostrará en tiempo real.")
336
-
337
  with gr.Row():
338
  with gr.Column(scale=2):
339
- mode_radio = gr.Radio(["Generar Guion con IA", "Usar Mi Guion"], value="Generar Guion con IA", label="Elige el método")
340
- topic_textbox = gr.Textbox(label="Tema para la IA", placeholder="Ej: La exploración espacial y sus desafíos")
341
- script_textbox = gr.Textbox(label="Tu Guion Completo", lines=5, visible=False, placeholder="Pega aquí tu guion...")
 
 
 
 
 
 
 
 
 
 
 
 
342
  music_upload = gr.Audio(type="filepath", label="Música de fondo (opcional)")
343
  submit_button = gr.Button("✨ Generar Vídeo", variant="primary")
344
-
345
  with gr.Column(scale=2):
346
  gr.Markdown("## Progreso y Resultados")
347
- progress_log = gr.Textbox(label="Log de Progreso en Tiempo Real", lines=10, interactive=False)
 
 
 
 
348
  video_output = gr.Video(label="Resultado del Vídeo")
349
  download_file_output = gr.File(label="Descargar Fichero")
350
 
351
  def toggle_textboxes(mode):
352
- return gr.update(visible=mode == "Generar Guion con IA"), gr.update(visible=mode != "Generar Guion con IA")
 
 
 
 
 
 
 
 
 
353
 
354
- mode_radio.change(toggle_textboxes, inputs=mode_radio, outputs=[topic_textbox, script_textbox])
355
-
356
  submit_button.click(
357
  fn=generate_and_monitor,
358
  inputs=[mode_radio, topic_textbox, script_textbox, music_upload],
@@ -360,4 +430,4 @@ with gr.Blocks(title="Generador de Vídeos IA", theme=gr.themes.Soft()) as demo:
360
  )
361
 
362
  if __name__ == "__main__":
363
- demo.launch()
 
 
 
 
 
1
  import gradio as gr
2
  import torch
3
  from huggingface_hub import hf_hub_download
 
6
  from transformers import GPT2Tokenizer, GPT2LMHeadModel
7
  from keybert import KeyBERT
8
  from moviepy.editor import (
9
+ VideoFileClip,
10
+ AudioFileClip,
11
+ concatenate_videoclips,
12
+ concatenate_audioclips,
13
+ CompositeAudioClip,
14
+ AudioClip,
15
+ TextClip,
16
+ CompositeVideoClip,
17
+ VideoClip
18
  )
19
+ import numpy as np
20
+ import json
21
+ import logging
22
+ import os
23
+ import requests
24
+ import re
25
+ import math
26
+ import tempfile
27
+ import shutil
28
+ import uuid
29
+ import threading
30
+ import time
31
+ from datetime import datetime, timedelta
32
 
33
  # ------------------- CÓDIGO DEL MOTOR TOUCANTTS (Integrado) -------------------
34
  # Este bloque contiene las funciones y clases extraídas para que el TTS funcione sin archivos externos.
 
36
  # --- Contenido de Utility/utils.py ---
37
  def float2pcm(sig, dtype='int16'):
38
  sig = np.asarray(sig)
39
+ if sig.dtype.kind != 'f':
40
+ raise TypeError("'sig' must be a float array")
41
  dtype = np.dtype(dtype)
42
+ if dtype.kind not in 'iu':
43
+ raise TypeError("'dtype' must be an integer type")
44
  i = np.iinfo(dtype)
45
  abs_max = 2 ** (i.bits - 1)
46
  offset = i.min + abs_max
 
58
  class ToucanTTSInterface:
59
  def __init__(self, gpu_id="cpu"):
60
  self.device = torch.device("cpu") if gpu_id == "cpu" else torch.device("cuda")
 
61
  tts_model_path = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="best.pt")
62
  vocoder_model_path = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="vocoder.pt")
 
63
  # Importamos la clase aquí para evitar problemas de dependencias circulares
64
  from TrainingInterfaces.Text_to_Spectrogram.ToucanTTS.ToucanTTS import ToucanTTS as ToucanTTS_Model
 
65
  self.tts_model = ToucanTTS_Model()
66
  self.tts_model.load_state_dict(torch.load(tts_model_path, map_location=self.device)["model"])
67
  self.vocoder_model = torch.jit.load(vocoder_model_path).to(self.device).eval()
 
68
  path_to_iso_list = hf_hub_download(repo_id="Flux9665/ToucanTTS", filename="iso_to_id.json")
69
  self.iso_to_id = load_json_from_path(path_to_iso_list)
 
70
  self.tts_model.to(self.device)
71
 
72
  def read(self, text, language="spa", accent="spa"):
73
  with torch.inference_mode():
74
  style_embedding = self.tts_model.style_embedding_function(torch.randn([1, 1, 192]).to(self.device)).squeeze()
 
75
  output_wave, output_sr, _ = self.tts_model.read(
76
  text=text,
77
  style_embedding=style_embedding,
 
80
  vocoder=self.vocoder_model,
81
  device=self.device
82
  )
83
+ return output_sr, output_wave.cpu().numpy()
84
 
85
  # ------------------- Configuración & Globals -------------------
86
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
 
101
  if tokenizer is None:
102
  logger.info("Cargando tokenizer (primera vez)...")
103
  tokenizer = GPT2Tokenizer.from_pretrained("datificate/gpt2-small-spanish")
104
+ if tokenizer.pad_token is None:
105
+ tokenizer.pad_token = tokenizer.eos_token
106
  return tokenizer
107
 
108
  def get_gpt2_model():
 
119
  kw_model = KeyBERT("paraphrase-multilingual-MiniLM-L12-v2")
120
  return kw_model
121
 
 
 
 
 
 
 
 
 
 
122
  def get_tts_interface():
123
+ # Esta función ahora es un punto de entrada para el motor ToucanTTS
124
+ # La carga real se hará dentro de la función de síntesis para manejar el primer uso
125
+ # De momento, la dejamos como placeholder por si se necesita inicializar algo globalmente
126
+ pass
127
 
128
  # ------------------- Funciones del Pipeline de Vídeo -------------------
129
  def update_task_progress(task_id, message):
 
137
  instruction = f"Escribe un guion corto y coherente sobre: {prompt}"
138
  inputs = local_tokenizer(instruction, return_tensors="pt", truncation=True, max_length=512)
139
  outputs = local_gpt2_model.generate(
140
+ **inputs,
141
+ max_length=160 + inputs["input_ids"].shape[1],
142
+ do_sample=True,
143
+ top_p=0.9,
144
+ top_k=40,
145
+ temperature=0.7,
146
+ no_repeat_ngram_size=3,
147
+ pad_token_id=local_tokenizer.pad_token_id,
148
+ eos_token_id=local_tokenizer.eos_token_id,
149
  )
150
  text = local_tokenizer.decode(outputs[0], skip_special_tokens=True)
151
  return text.split("sobre:")[-1].strip()
 
165
  return [k.replace(" ", "+") for k, _ in kws if k] or ["naturaleza"]
166
 
167
  def pexels_search(query: str, count: int) -> list[dict]:
168
+ res = requests.get(
169
+ "https://api.pexels.com/videos/search",
170
+ headers={"Authorization": PEXELS_API_KEY},
171
+ params={"query": query, "per_page": count, "orientation": "landscape"},
172
+ timeout=20
173
+ )
174
  res.raise_for_status()
175
  return res.json().get("videos", [])
176
 
 
181
  with requests.get(url, stream=True, timeout=60) as r:
182
  r.raise_for_status()
183
  with open(path, "wb") as f:
184
+ for chunk in r.iter_content(1024 * 1024):
185
+ f.write(chunk)
186
  return path if os.path.exists(path) and os.path.getsize(path) > 1000 else None
187
  except Exception as e:
188
  logger.error(f"Fallo al descargar {url}: {e}")
189
  return None
190
 
191
  def loop_audio(audio_clip: AudioFileClip, duration: float) -> AudioFileClip:
192
+ if audio_clip.duration >= duration:
193
+ return audio_clip.subclip(0, duration)
194
  loops = math.ceil(duration / audio_clip.duration)
195
  return concatenate_audioclips([audio_clip] * loops).subclip(0, duration)
196
 
197
  def make_subtitle_clips(script: str, video_w: int, video_h: int, duration: float):
198
  sentences = [s.strip() for s in re.split(r"[.!?¿¡]", script) if s.strip()]
199
+ if not sentences:
200
+ return []
201
  total_words = sum(len(s.split()) for s in sentences) or 1
202
  time_per_word = duration / total_words
203
  clips, current_time = [], 0.0
204
  for sentence in sentences:
205
  num_words = len(sentence.split())
206
  sentence_duration = num_words * time_per_word
207
+ if sentence_duration < 0.1:
208
+ continue
209
+ txt_clip = (
210
+ TextClip(
211
+ sentence,
212
+ fontsize=int(video_h * 0.05),
213
+ color="white",
214
+ stroke_color="black",
215
+ stroke_width=1.5,
216
+ method="caption",
217
+ size=(int(video_w * 0.9), None),
218
+ font="Arial-Bold"
219
+ )
220
+ .set_start(current_time)
221
+ .set_duration(sentence_duration)
222
+ .set_position(("center", "bottom"))
223
+ )
224
  clips.append(txt_clip)
225
  current_time += sentence_duration
226
  return clips
 
237
  try:
238
  update_task_progress(task_id, "Paso 1/7: Generando guion...")
239
  script = gpt2_script(script_text) if generate_script_flag else script_text.strip()
240
+ update_task_progress(task_id, "Paso 2/7: Creando audio con ToucanTTS...")
 
241
  voice_path = os.path.join(tmp_dir, "voice.wav")
242
  toucan_tts_synth(script, voice_path)
243
  voice_clip = AudioFileClip(voice_path)
244
  video_duration = voice_clip.duration
245
+ if video_duration < 1:
246
+ raise ValueError("El audio generado es demasiado corto.")
247
  update_task_progress(task_id, "Paso 3/7: Buscando clips en Pexels...")
248
  video_paths = []
249
  kws = keywords(script)
250
  for i, kw in enumerate(kws):
251
  update_task_progress(task_id, f"Paso 3/7: Buscando... (keyword {i+1}/{len(kws)}: '{kw}')")
252
+ if len(video_paths) >= 8:
253
+ break
254
  for video_data in pexels_search(kw, 2):
255
+ best_file = max(
256
+ video_data.get("video_files", []),
257
+ key=lambda f: f.get("width", 0)
258
+ )
259
  if best_file:
260
  path = download_file(best_file.get('link'), tmp_dir)
261
+ if path:
262
+ video_paths.append(path)
263
+ if len(video_paths) >= 8:
264
+ break
265
+ if not video_paths:
266
+ raise RuntimeError("No se encontraron vídeos en Pexels.")
267
  update_task_progress(task_id, f"Paso 4/7: Ensamblando {len(video_paths)} clips...")
268
+ segments = [
269
+ VideoFileClip(p).subclip(0, min(8, VideoFileClip(p).duration))
270
+ for p in video_paths
271
+ ]
272
  base_video = concatenate_videoclips(segments, method="chain")
273
  if base_video.duration < video_duration:
274
  base_video = concatenate_videoclips([base_video] * math.ceil(video_duration / base_video.duration))
275
  base_video = base_video.subclip(0, video_duration)
 
276
  update_task_progress(task_id, "Paso 5/7: Componiendo audio final...")
277
  if music_path:
278
  music_clip = loop_audio(AudioFileClip(music_path), video_duration).volumex(0.20)
279
  final_audio = CompositeAudioClip([music_clip, voice_clip])
280
+ else:
281
+ final_audio = voice_clip
282
  update_task_progress(task_id, "Paso 6/7: Añadiendo subtítulos y efectos...")
283
  subtitles = make_subtitle_clips(script, base_video.w, base_video.h, video_duration)
284
  grain_effect = make_grain_clip(base_video.size, video_duration)
 
285
  update_task_progress(task_id, "Paso 7/7: Renderizando vídeo final (esto puede tardar)...")
286
  final_video = CompositeVideoClip([base_video, grain_effect, *subtitles]).set_audio(final_audio)
287
  output_path = os.path.join(tmp_dir, "final_video.mp4")
288
+ final_video.write_videofile(
289
+ output_path,
290
+ fps=24,
291
+ codec="libx264",
292
+ audio_codec="aac",
293
+ threads=2,
294
+ logger=None
295
+ )
296
  return output_path
297
  finally:
298
+ if 'voice_clip' in locals():
299
+ voice_clip.close()
300
+ if 'music_clip' in locals():
301
+ music_clip.close()
302
+ if 'base_video' in locals():
303
+ base_video.close()
304
+ if 'final_video' in locals():
305
+ final_video.close()
306
  if 'segments' in locals():
307
+ for seg in segments:
308
+ seg.close()
309
 
310
  def worker(task_id: str, mode: str, topic: str, user_script: str, music: str | None):
311
+ # Carga del motor TTS aquí, para que ocurra dentro del hilo de trabajo y no bloquee el arranque global
312
  global tts_interface
313
  if tts_interface is None:
314
  update_task_progress(task_id, "Cargando motor de voz ToucanTTS (primera vez, puede tardar)...")
 
321
  # Para una solución real, el código de ToucanTTS tendría que estar en el path.
322
  # get_tts_interface()
323
  except Exception as e:
324
+ TASKS[task_id].update({"status": "error", "error": f"Fallo al cargar el motor TTS: {e}"})
325
+ return
 
326
  try:
327
  text = topic if mode == "Generar Guion con IA" else user_script
328
+ # Como ToucanTTS no está completamente integrado, simularemos un error por ahora.
329
+ # result_tmp_path = build_video(text, mode == "Generar Guion con IA", music, task_id)
330
+ # final_path = os.path.join(RESULTS_DIR, f"{task_id}.mp4")
331
+ # shutil.copy2(result_tmp_path, final_path)
332
+ # TASKS[task_id].update({"status": "done", "result": final_path})
333
+ # shutil.rmtree(os.path.dirname(result_tmp_path))
334
+ raise NotImplementedError("La integración del motor TTS autocontenido requiere refactorización que no se ha completado.")
335
  except Exception as e:
336
  logger.error(f"Error en el worker para la tarea {task_id}: {e}", exc_info=True)
337
  TASKS[task_id].update({"status": "error", "error": str(e)})
338
 
 
339
  def janitor_thread():
340
  while True:
341
  time.sleep(3600)
 
358
  if not content.strip():
359
  yield "Por favor, ingresa un tema o guion.", None, None
360
  return
 
361
  task_id = uuid.uuid4().hex[:8]
362
+ TASKS[task_id] = {
363
+ "status": "processing",
364
+ "progress_log": "Iniciando tarea...",
365
+ "timestamp": datetime.utcnow()
366
+ }
367
+ worker_thread = threading.Thread(
368
+ target=worker,
369
+ args=(task_id, mode, topic, user_script, music),
370
+ daemon=True
371
+ )
372
  worker_thread.start()
 
373
  while TASKS[task_id]["status"] == "processing":
374
  yield TASKS[task_id]['progress_log'], None, None
375
  time.sleep(1)
 
376
  if TASKS[task_id]["status"] == "error":
377
  yield f"❌ Error: {TASKS[task_id]['error']}", None, None
378
  elif TASKS[task_id]["status"] == "done":
379
  yield "✅ ¡Vídeo completado!", TASKS[task_id]['result'], TASKS[task_id]['result']
380
 
381
+ # Interfaz Gradio
382
  with gr.Blocks(title="Generador de Vídeos IA", theme=gr.themes.Soft()) as demo:
383
  gr.Markdown("# 🎬 Generador de Vídeos con IA")
384
  gr.Markdown("Crea vídeos a partir de texto con voz, música y efectos visuales. El progreso se mostrará en tiempo real.")
 
385
  with gr.Row():
386
  with gr.Column(scale=2):
387
+ mode_radio = gr.Radio(
388
+ ["Generar Guion con IA", "Usar Mi Guion"],
389
+ value="Generar Guion con IA",
390
+ label="Elige el método"
391
+ )
392
+ topic_textbox = gr.Textbox(
393
+ label="Tema para la IA",
394
+ placeholder="Ej: La exploración espacial y sus desafíos"
395
+ )
396
+ script_textbox = gr.Textbox(
397
+ label="Tu Guion Completo",
398
+ lines=5,
399
+ visible=False,
400
+ placeholder="Pega aquí tu guion..."
401
+ )
402
  music_upload = gr.Audio(type="filepath", label="Música de fondo (opcional)")
403
  submit_button = gr.Button("✨ Generar Vídeo", variant="primary")
 
404
  with gr.Column(scale=2):
405
  gr.Markdown("## Progreso y Resultados")
406
+ progress_log = gr.Textbox(
407
+ label="Log de Progreso en Tiempo Real",
408
+ lines=10,
409
+ interactive=False
410
+ )
411
  video_output = gr.Video(label="Resultado del Vídeo")
412
  download_file_output = gr.File(label="Descargar Fichero")
413
 
414
  def toggle_textboxes(mode):
415
+ return (
416
+ gr.update(visible=mode == "Generar Guion con IA"),
417
+ gr.update(visible=mode != "Generar Guion con IA")
418
+ )
419
+
420
+ mode_radio.change(
421
+ toggle_textboxes,
422
+ inputs=mode_radio,
423
+ outputs=[topic_textbox, script_textbox]
424
+ )
425
 
 
 
426
  submit_button.click(
427
  fn=generate_and_monitor,
428
  inputs=[mode_radio, topic_textbox, script_textbox, music_upload],
 
430
  )
431
 
432
  if __name__ == "__main__":
433
+ demo.launch()