VeuReu commited on
Commit
86f2273
·
1 Parent(s): 7efd279

Upload 2 files

Browse files
Files changed (2) hide show
  1. api_client.py +337 -324
  2. app.py +43 -2
api_client.py CHANGED
@@ -1,324 +1,337 @@
1
- # api_client.py (UI - Space "veureu")
2
- import os
3
- import requests
4
- import base64
5
- import zipfile
6
- import io
7
- from typing import Iterable, Dict, Any
8
-
9
- class APIClient:
10
- """
11
- Cliente para 'engine':
12
- POST /jobs -> {"job_id": "..."}
13
- GET /jobs/{job_id}/status -> {"status": "queued|processing|done|failed", ...}
14
- GET /jobs/{job_id}/result -> JobResult {"book": {...}, "une": {...}, ...}
15
- """
16
- def __init__(self, base_url: str, use_mock: bool = False, data_dir: str | None = None, token: str | None = None, timeout: int = 180):
17
- self.base_url = base_url.rstrip("/")
18
- # La URL para el servicio TTS es la misma que la base_url para los Spaces de HF
19
- self.tts_url = self.base_url
20
- self.use_mock = use_mock
21
- self.data_dir = data_dir
22
- self.timeout = timeout
23
- self.session = requests.Session()
24
- # Permite inyectar el token del engine via secret/var en el Space UI
25
- token = token or os.getenv("API_SHARED_TOKEN")
26
- if token:
27
- self.session.headers.update({"Authorization": f"Bearer {token}"})
28
-
29
- # ---- modo real (engine) ----
30
- def _post_jobs(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
31
- url = f"{self.base_url}/jobs"
32
- files = {"file": (os.path.basename(video_path), open(video_path, "rb"), "application/octet-stream")}
33
- data = {"modes": ",".join(modes)}
34
- r = self.session.post(url, files=files, data=data, timeout=self.timeout)
35
- r.raise_for_status()
36
- return r.json() # {"job_id": ...}
37
-
38
- def _get_status(self, job_id: str) -> Dict[str, Any]:
39
- url = f"{self.base_url}/jobs/{job_id}/status"
40
- r = self.session.get(url, timeout=self.timeout)
41
- r.raise_for_status()
42
- return r.json()
43
-
44
- def _get_result(self, job_id: str) -> Dict[str, Any]:
45
- url = f"{self.base_url}/jobs/{job_id}/status"
46
- r = self.session.get(url, timeout=self.timeout)
47
- r.raise_for_status()
48
- return r.json() # JobResult (status + results según engine)
49
-
50
- # ---- API que usa streamlit_app.py ----
51
- def process_video(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
52
- """Devuelve {"job_id": "..."}"""
53
- if self.use_mock:
54
- return {"job_id": "mock-123"}
55
- return self._post_jobs(video_path, modes)
56
-
57
- def get_job(self, job_id: str) -> Dict[str, Any]:
58
- """
59
- La UI espera algo del estilo:
60
- {"status":"done","results":{"book":{...},"une":{...}}}
61
- Adaptamos la respuesta de /result del engine a ese contrato.
62
- """
63
- if self.use_mock:
64
- # resultado inmediato de prueba
65
- return {
66
- "status": "done",
67
- "results": {
68
- "book": {"text": "Text d'exemple (book)", "mp3_bytes": b""},
69
- "une": {"srt": "1\n00:00:00,000 --> 00:00:01,000\nExemple UNE\n", "mp3_bytes": b""},
70
- }
71
- }
72
-
73
- # Opción 1: chequear estado primero
74
- st = self._get_status(job_id)
75
- if st.get("status") in {"queued", "processing"}:
76
- return {"status": st.get("status", "queued")}
77
-
78
- # Opción 2: obtener resultado final
79
- res = self._get_result(job_id)
80
-
81
- # NUEVO: si el engine ya devuelve {"status": ..., "results": {...}}, pásalo tal cual
82
- if isinstance(res, dict) and isinstance(res.get("results"), dict):
83
- return {
84
- "status": res.get("status", st.get("status", "done")),
85
- "results": res.get("results", {}),
86
- }
87
-
88
- # LEGACY: mapeo antiguo basado en claves top-level (book/une)
89
- results = {}
90
- if "book" in res:
91
- results["book"] = {
92
- "text": res["book"].get("text"),
93
- }
94
- if "une" in res:
95
- results["une"] = {
96
- "srt": res["une"].get("srt"),
97
- }
98
- for k in ("book", "une"):
99
- if k in res:
100
- if "characters" in res[k]:
101
- results[k]["characters"] = res[k]["characters"]
102
- if "metrics" in res[k]:
103
- results[k]["metrics"] = res[k]["metrics"]
104
-
105
- status = "done" if results else st.get("status", "unknown")
106
- return {"status": status, "results": results}
107
-
108
-
109
- def tts_matxa(self, text: str, voice: str = "central/grau") -> dict:
110
- """
111
- Llama al space 'tts' para sintetizar audio.
112
-
113
- Args:
114
- text (str): Texto a sintetizar.
115
- voice (str): Voz de Matxa a usar (p.ej. 'central/alvocat').
116
-
117
- Returns:
118
- dict: {'mp3_data_url': 'data:audio/mpeg;base64,...'}
119
- """
120
- if not self.tts_url:
121
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
122
-
123
- url = f"{self.tts_url.rstrip('/')}/tts/text"
124
- data = {
125
- "texto": text,
126
- "voice": voice,
127
- "formato": "mp3"
128
- }
129
-
130
- try:
131
- r = requests.post(url, data=data, timeout=self.timeout)
132
- r.raise_for_status()
133
-
134
- # Devolver los bytes directamente para que el cliente los pueda concatenar
135
- return {"mp3_bytes": r.content}
136
-
137
- except requests.exceptions.RequestException as e:
138
- print(f"Error cridant a TTS: {e}")
139
- # Devolvemos un diccionario con error para que la UI lo muestre
140
- return {"error": str(e)}
141
-
142
-
143
- def rebuild_video_with_ad(self, video_path: str, srt_path: str) -> dict:
144
- """
145
- Llama al space 'tts' para reconstruir un vídeo con audiodescripció a partir de un SRT.
146
- El servidor devuelve un ZIP, y de ahí extraemos el MP4 final.
147
- """
148
- if not self.tts_url:
149
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
150
-
151
- url = f"{self.tts_url.rstrip('/')}/tts/srt"
152
-
153
- try:
154
- files = {
155
- 'video': (os.path.basename(video_path), open(video_path, 'rb'), 'video/mp4'),
156
- 'srt': (os.path.basename(srt_path), open(srt_path, 'rb'), 'application/x-subrip')
157
- }
158
- data = {"include_final_mp4": 1}
159
-
160
- r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
161
- r.raise_for_status()
162
-
163
- # El servidor devuelve un ZIP, lo procesamos en memoria
164
- with zipfile.ZipFile(io.BytesIO(r.content)) as z:
165
- # Buscamos el archivo .mp4 dentro del ZIP
166
- for filename in z.namelist():
167
- if filename.endswith('.mp4'):
168
- video_bytes = z.read(filename)
169
- return {"video_bytes": video_bytes}
170
-
171
- # Si no se encuentra el MP4 en el ZIP
172
- return {"error": "No se encontró el archivo de vídeo MP4 en la respuesta del servidor."}
173
-
174
- except requests.exceptions.RequestException as e:
175
- print(f"Error cridant a la reconstrucció de vídeo: {e}")
176
- return {"error": str(e)}
177
- except zipfile.BadZipFile:
178
- return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
179
-
180
-
181
- def refine_narration(self, dialogues_srt: str, frame_descriptions_json: str = "[]", config_path: str = "config.yaml") -> dict:
182
- """Llama al endpoint del engine /refine_narration para generar narrativa y/o SRT."""
183
- url = f"{self.base_url}/refine_narration"
184
- data = {
185
- "dialogues_srt": dialogues_srt,
186
- "frame_descriptions_json": frame_descriptions_json,
187
- "config_path": config_path,
188
- }
189
- try:
190
- r = self.session.post(url, data=data, timeout=self.timeout)
191
- r.raise_for_status()
192
- return r.json()
193
- except requests.exceptions.RequestException as e:
194
- return {"error": str(e)}
195
-
196
-
197
- def create_initial_casting(self, video_path: str = None, video_bytes: bytes = None, video_name: str = None, epsilon: float = 0.5, min_cluster_size: int = 2) -> dict:
198
- """
199
- Llama al endpoint del space 'engine' para crear el 'initial casting'.
200
-
201
- Envía el vídeo recién importado como archivo y los parámetros de clustering.
202
-
203
- Args:
204
- video_path: Path to video file (if reading from disk)
205
- video_bytes: Video file bytes (if already in memory)
206
- video_name: Name for the video file
207
- epsilon: Clustering epsilon parameter
208
- min_cluster_size: Minimum cluster size parameter
209
- """
210
- url = f"{self.base_url}/create_initial_casting"
211
- try:
212
- # Prepare file data
213
- if video_bytes:
214
- filename = video_name or "video.mp4"
215
- files = {
216
- "video": (filename, video_bytes, "video/mp4"),
217
- }
218
- elif video_path:
219
- with open(video_path, "rb") as f:
220
- files = {
221
- "video": (os.path.basename(video_path), f.read(), "video/mp4"),
222
- }
223
- else:
224
- return {"error": "Either video_path or video_bytes must be provided"}
225
-
226
- data = {
227
- "epsilon": str(epsilon),
228
- "min_cluster_size": str(min_cluster_size),
229
- }
230
- r = self.session.post(url, files=files, data=data, timeout=self.timeout * 5)
231
- r.raise_for_status()
232
- return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
233
- except requests.exceptions.RequestException as e:
234
- return {"error": str(e)}
235
- except Exception as e:
236
- return {"error": f"Unexpected error: {str(e)}"}
237
-
238
- def generate_audio_from_text_file(self, text_content: str, voice: str = "central/grau") -> dict:
239
- """
240
- Genera un único MP3 a partir de un texto largo, usando el endpoint de SRT.
241
- 1. Convierte el texto en un SRT falso.
242
- 2. Llama a /tts/srt con el SRT.
243
- 3. Extrae el 'ad_master.mp3' del ZIP resultante.
244
- """
245
- if not self.tts_url:
246
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
247
-
248
- # 1. Crear un SRT falso en memoria
249
- srt_content = ""
250
- start_time = 0
251
- for i, line in enumerate(text_content.strip().split('\n')):
252
- line = line.strip()
253
- if not line:
254
- continue
255
- # Asignar 5 segundos por línea, un valor simple
256
- end_time = start_time + 5
257
-
258
- def format_time(seconds):
259
- h = int(seconds / 3600)
260
- m = int((seconds % 3600) / 60)
261
- s = int(seconds % 60)
262
- ms = int((seconds - int(seconds)) * 1000)
263
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
264
-
265
- srt_content += f"{i+1}\n"
266
- srt_content += f"{format_time(start_time)} --> {format_time(end_time)}\n"
267
- srt_content += f"{line}\n\n"
268
- start_time = end_time
269
-
270
- if not srt_content:
271
- return {"error": "El texto proporcionado estaba vacío o no se pudo procesar."}
272
-
273
- # 2. Llamar al endpoint /tts/srt
274
- url = f"{self.tts_url.rstrip('/')}/tts/srt"
275
- try:
276
- files = {
277
- 'srt': ('fake_ad.srt', srt_content, 'application/x-subrip')
278
- }
279
- data = {"voice": voice, "ad_format": "mp3"}
280
-
281
- r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
282
- r.raise_for_status()
283
-
284
- # 3. Extraer 'ad_master.mp3' del ZIP
285
- with zipfile.ZipFile(io.BytesIO(r.content)) as z:
286
- for filename in z.namelist():
287
- if filename == 'ad_master.mp3':
288
- mp3_bytes = z.read(filename)
289
- return {"mp3_bytes": mp3_bytes}
290
-
291
- return {"error": "No se encontró 'ad_master.mp3' en la respuesta del servidor."}
292
-
293
- except requests.exceptions.RequestException as e:
294
- return {"error": f"Error llamando a la API de SRT: {e}"}
295
- except zipfile.BadZipFile:
296
- return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
297
-
298
-
299
- def tts_long_text(self, text: str, voice: str = "central/grau") -> dict:
300
- """
301
- Llama al endpoint '/tts/text_long' para sintetizar un texto largo.
302
- La API se encarga de todo el procesamiento.
303
- """
304
- if not self.tts_url:
305
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
306
-
307
- url = f"{self.tts_url.rstrip('/')}/tts/text_long"
308
- data = {
309
- "texto": text,
310
- "voice": voice,
311
- "formato": "mp3"
312
- }
313
-
314
- try:
315
- # Usamos un timeout más largo por si el texto es muy extenso
316
- r = requests.post(url, data=data, timeout=self.timeout * 10)
317
- r.raise_for_status()
318
- return {"mp3_bytes": r.content}
319
-
320
- except requests.exceptions.RequestException as e:
321
- print(f"Error cridant a TTS per a text llarg: {e}")
322
- return {"error": str(e)}
323
-
324
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api_client.py (UI - Space "veureu")
2
+ import os
3
+ import requests
4
+ import base64
5
+ import zipfile
6
+ import io
7
+ from typing import Iterable, Dict, Any
8
+
9
+ class APIClient:
10
+ """
11
+ Cliente para 'engine':
12
+ POST /jobs -> {"job_id": "..."}
13
+ GET /jobs/{job_id}/status -> {"status": "queued|processing|done|failed", ...}
14
+ GET /jobs/{job_id}/result -> JobResult {"book": {...}, "une": {...}, ...}
15
+ """
16
+ def __init__(self, base_url: str, use_mock: bool = False, data_dir: str | None = None, token: str | None = None, timeout: int = 180):
17
+ self.base_url = base_url.rstrip("/")
18
+ # La URL para el servicio TTS es la misma que la base_url para los Spaces de HF
19
+ self.tts_url = self.base_url
20
+ self.use_mock = use_mock
21
+ self.data_dir = data_dir
22
+ self.timeout = timeout
23
+ self.session = requests.Session()
24
+ # Permite inyectar el token del engine via secret/var en el Space UI
25
+ token = token or os.getenv("API_SHARED_TOKEN")
26
+ if token:
27
+ self.session.headers.update({"Authorization": f"Bearer {token}"})
28
+
29
+ # ---- modo real (engine) ----
30
+ def _post_jobs(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
31
+ url = f"{self.base_url}/jobs"
32
+ files = {"file": (os.path.basename(video_path), open(video_path, "rb"), "application/octet-stream")}
33
+ data = {"modes": ",".join(modes)}
34
+ r = self.session.post(url, files=files, data=data, timeout=self.timeout)
35
+ r.raise_for_status()
36
+ return r.json() # {"job_id": ...}
37
+
38
+ def _get_status(self, job_id: str) -> Dict[str, Any]:
39
+ url = f"{self.base_url}/jobs/{job_id}/status"
40
+ r = self.session.get(url, timeout=self.timeout)
41
+ r.raise_for_status()
42
+ return r.json()
43
+
44
+ def _get_result(self, job_id: str) -> Dict[str, Any]:
45
+ url = f"{self.base_url}/jobs/{job_id}/status"
46
+ r = self.session.get(url, timeout=self.timeout)
47
+ r.raise_for_status()
48
+ return r.json() # JobResult (status + results según engine)
49
+
50
+ # ---- API que usa streamlit_app.py ----
51
+ def process_video(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
52
+ """Devuelve {"job_id": "..."}"""
53
+ if self.use_mock:
54
+ return {"job_id": "mock-123"}
55
+ return self._post_jobs(video_path, modes)
56
+
57
+ def get_job(self, job_id: str) -> Dict[str, Any]:
58
+ """
59
+ La UI espera algo del estilo:
60
+ {"status":"done","results":{"book":{...},"une":{...}}}
61
+ Adaptamos la respuesta de /result del engine a ese contrato.
62
+ """
63
+ if self.use_mock:
64
+ # resultado inmediato de prueba
65
+ return {
66
+ "status": "done",
67
+ "results": {
68
+ "book": {"text": "Text d'exemple (book)", "mp3_bytes": b""},
69
+ "une": {"srt": "1\n00:00:00,000 --> 00:00:01,000\nExemple UNE\n", "mp3_bytes": b""},
70
+ }
71
+ }
72
+
73
+ # Opción 1: chequear estado primero
74
+ st = self._get_status(job_id)
75
+ if st.get("status") in {"queued", "processing"}:
76
+ return {"status": st.get("status", "queued")}
77
+
78
+ # Opción 2: obtener resultado final
79
+ res = self._get_result(job_id)
80
+
81
+ # NUEVO: si el engine ya devuelve {"status": ..., "results": {...}}, pásalo tal cual
82
+ if isinstance(res, dict) and isinstance(res.get("results"), dict):
83
+ return {
84
+ "status": res.get("status", st.get("status", "done")),
85
+ "results": res.get("results", {}),
86
+ }
87
+
88
+ # LEGACY: mapeo antiguo basado en claves top-level (book/une)
89
+ results = {}
90
+ if "book" in res:
91
+ results["book"] = {
92
+ "text": res["book"].get("text"),
93
+ }
94
+ if "une" in res:
95
+ results["une"] = {
96
+ "srt": res["une"].get("srt"),
97
+ }
98
+ for k in ("book", "une"):
99
+ if k in res:
100
+ if "characters" in res[k]:
101
+ results[k]["characters"] = res[k]["characters"]
102
+ if "metrics" in res[k]:
103
+ results[k]["metrics"] = res[k]["metrics"]
104
+
105
+ status = "done" if results else st.get("status", "unknown")
106
+ return {"status": status, "results": results}
107
+
108
+
109
+ def tts_matxa(self, text: str, voice: str = "central/grau") -> dict:
110
+ """
111
+ Llama al space 'tts' para sintetizar audio.
112
+
113
+ Args:
114
+ text (str): Texto a sintetizar.
115
+ voice (str): Voz de Matxa a usar (p.ej. 'central/alvocat').
116
+
117
+ Returns:
118
+ dict: {'mp3_data_url': 'data:audio/mpeg;base64,...'}
119
+ """
120
+ if not self.tts_url:
121
+ raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
122
+
123
+ url = f"{self.tts_url.rstrip('/')}/tts/text"
124
+ data = {
125
+ "texto": text,
126
+ "voice": voice,
127
+ "formato": "mp3"
128
+ }
129
+
130
+ try:
131
+ r = requests.post(url, data=data, timeout=self.timeout)
132
+ r.raise_for_status()
133
+
134
+ # Devolver los bytes directamente para que el cliente los pueda concatenar
135
+ return {"mp3_bytes": r.content}
136
+
137
+ except requests.exceptions.RequestException as e:
138
+ print(f"Error cridant a TTS: {e}")
139
+ # Devolvemos un diccionario con error para que la UI lo muestre
140
+ return {"error": str(e)}
141
+
142
+ def generate_audiodescription(self, video_bytes: bytes, video_name: str) -> dict:
143
+ """Llama al endpoint del engine /generate_audiodescription con un MP4 en memoria."""
144
+ url = f"{self.base_url}/generate_audiodescription"
145
+ try:
146
+ files = {
147
+ "video": (video_name or "video.mp4", video_bytes, "video/mp4")
148
+ }
149
+ r = self.session.post(url, files=files, timeout=self.timeout * 10)
150
+ r.raise_for_status()
151
+ return r.json()
152
+ except requests.exceptions.RequestException as e:
153
+ return {"error": str(e)}
154
+
155
+
156
+ def rebuild_video_with_ad(self, video_path: str, srt_path: str) -> dict:
157
+ """
158
+ Llama al space 'tts' para reconstruir un vídeo con audiodescripció a partir de un SRT.
159
+ El servidor devuelve un ZIP, y de ahí extraemos el MP4 final.
160
+ """
161
+ if not self.tts_url:
162
+ raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
163
+
164
+ url = f"{self.tts_url.rstrip('/')}/tts/srt"
165
+
166
+ try:
167
+ files = {
168
+ 'video': (os.path.basename(video_path), open(video_path, 'rb'), 'video/mp4'),
169
+ 'srt': (os.path.basename(srt_path), open(srt_path, 'rb'), 'application/x-subrip')
170
+ }
171
+ data = {"include_final_mp4": 1}
172
+
173
+ r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
174
+ r.raise_for_status()
175
+
176
+ # El servidor devuelve un ZIP, lo procesamos en memoria
177
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
178
+ # Buscamos el archivo .mp4 dentro del ZIP
179
+ for filename in z.namelist():
180
+ if filename.endswith('.mp4'):
181
+ video_bytes = z.read(filename)
182
+ return {"video_bytes": video_bytes}
183
+
184
+ # Si no se encuentra el MP4 en el ZIP
185
+ return {"error": "No se encontró el archivo de vídeo MP4 en la respuesta del servidor."}
186
+
187
+ except requests.exceptions.RequestException as e:
188
+ print(f"Error cridant a la reconstrucció de vídeo: {e}")
189
+ return {"error": str(e)}
190
+ except zipfile.BadZipFile:
191
+ return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
192
+
193
+
194
+ def refine_narration(self, dialogues_srt: str, frame_descriptions_json: str = "[]", config_path: str = "config.yaml") -> dict:
195
+ """Llama al endpoint del engine /refine_narration para generar narrativa y/o SRT."""
196
+ url = f"{self.base_url}/refine_narration"
197
+ data = {
198
+ "dialogues_srt": dialogues_srt,
199
+ "frame_descriptions_json": frame_descriptions_json,
200
+ "config_path": config_path,
201
+ }
202
+ try:
203
+ r = self.session.post(url, data=data, timeout=self.timeout)
204
+ r.raise_for_status()
205
+ return r.json()
206
+ except requests.exceptions.RequestException as e:
207
+ return {"error": str(e)}
208
+
209
+
210
+ def create_initial_casting(self, video_path: str = None, video_bytes: bytes = None, video_name: str = None, epsilon: float = 0.5, min_cluster_size: int = 2) -> dict:
211
+ """
212
+ Llama al endpoint del space 'engine' para crear el 'initial casting'.
213
+
214
+ Envía el vídeo recién importado como archivo y los parámetros de clustering.
215
+
216
+ Args:
217
+ video_path: Path to video file (if reading from disk)
218
+ video_bytes: Video file bytes (if already in memory)
219
+ video_name: Name for the video file
220
+ epsilon: Clustering epsilon parameter
221
+ min_cluster_size: Minimum cluster size parameter
222
+ """
223
+ url = f"{self.base_url}/create_initial_casting"
224
+ try:
225
+ # Prepare file data
226
+ if video_bytes:
227
+ filename = video_name or "video.mp4"
228
+ files = {
229
+ "video": (filename, video_bytes, "video/mp4"),
230
+ }
231
+ elif video_path:
232
+ with open(video_path, "rb") as f:
233
+ files = {
234
+ "video": (os.path.basename(video_path), f.read(), "video/mp4"),
235
+ }
236
+ else:
237
+ return {"error": "Either video_path or video_bytes must be provided"}
238
+
239
+ data = {
240
+ "epsilon": str(epsilon),
241
+ "min_cluster_size": str(min_cluster_size),
242
+ }
243
+ r = self.session.post(url, files=files, data=data, timeout=self.timeout * 5)
244
+ r.raise_for_status()
245
+ return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
246
+ except requests.exceptions.RequestException as e:
247
+ return {"error": str(e)}
248
+ except Exception as e:
249
+ return {"error": f"Unexpected error: {str(e)}"}
250
+
251
+ def generate_audio_from_text_file(self, text_content: str, voice: str = "central/grau") -> dict:
252
+ """
253
+ Genera un único MP3 a partir de un texto largo, usando el endpoint de SRT.
254
+ 1. Convierte el texto en un SRT falso.
255
+ 2. Llama a /tts/srt con el SRT.
256
+ 3. Extrae el 'ad_master.mp3' del ZIP resultante.
257
+ """
258
+ if not self.tts_url:
259
+ raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
260
+
261
+ # 1. Crear un SRT falso en memoria
262
+ srt_content = ""
263
+ start_time = 0
264
+ for i, line in enumerate(text_content.strip().split('\n')):
265
+ line = line.strip()
266
+ if not line:
267
+ continue
268
+ # Asignar 5 segundos por línea, un valor simple
269
+ end_time = start_time + 5
270
+
271
+ def format_time(seconds):
272
+ h = int(seconds / 3600)
273
+ m = int((seconds % 3600) / 60)
274
+ s = int(seconds % 60)
275
+ ms = int((seconds - int(seconds)) * 1000)
276
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
277
+
278
+ srt_content += f"{i+1}\n"
279
+ srt_content += f"{format_time(start_time)} --> {format_time(end_time)}\n"
280
+ srt_content += f"{line}\n\n"
281
+ start_time = end_time
282
+
283
+ if not srt_content:
284
+ return {"error": "El texto proporcionado estaba vacío o no se pudo procesar."}
285
+
286
+ # 2. Llamar al endpoint /tts/srt
287
+ url = f"{self.tts_url.rstrip('/')}/tts/srt"
288
+ try:
289
+ files = {
290
+ 'srt': ('fake_ad.srt', srt_content, 'application/x-subrip')
291
+ }
292
+ data = {"voice": voice, "ad_format": "mp3"}
293
+
294
+ r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
295
+ r.raise_for_status()
296
+
297
+ # 3. Extraer 'ad_master.mp3' del ZIP
298
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
299
+ for filename in z.namelist():
300
+ if filename == 'ad_master.mp3':
301
+ mp3_bytes = z.read(filename)
302
+ return {"mp3_bytes": mp3_bytes}
303
+
304
+ return {"error": "No se encontró 'ad_master.mp3' en la respuesta del servidor."}
305
+
306
+ except requests.exceptions.RequestException as e:
307
+ return {"error": f"Error llamando a la API de SRT: {e}"}
308
+ except zipfile.BadZipFile:
309
+ return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
310
+
311
+
312
+ def tts_long_text(self, text: str, voice: str = "central/grau") -> dict:
313
+ """
314
+ Llama al endpoint '/tts/text_long' para sintetizar un texto largo.
315
+ La API se encarga de todo el procesamiento.
316
+ """
317
+ if not self.tts_url:
318
+ raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
319
+
320
+ url = f"{self.tts_url.rstrip('/')}/tts/text_long"
321
+ data = {
322
+ "texto": text,
323
+ "voice": voice,
324
+ "formato": "mp3"
325
+ }
326
+
327
+ try:
328
+ # Usamos un timeout más largo por si el texto es muy extenso
329
+ r = requests.post(url, data=data, timeout=self.timeout * 10)
330
+ r.raise_for_status()
331
+ return {"mp3_bytes": r.content}
332
+
333
+ except requests.exceptions.RequestException as e:
334
+ print(f"Error cridant a TTS per a text llarg: {e}")
335
+ return {"error": str(e)}
336
+
337
+
app.py CHANGED
@@ -445,6 +445,7 @@ if page == "Processar vídeo nou":
445
  import time
446
  max_attempts = 60 # 5 minutos máximo (5 segundos * 60)
447
  attempt = 0
 
448
 
449
  with message_placeholder:
450
  with st.spinner("⏳ Detectant personatges... Això pot trigar uns minuts."):
@@ -488,6 +489,27 @@ if page == "Processar vídeo nou":
488
  log(f"✗ Job falló: {error_msg}")
489
  st.error(f"❌ Error en el processament: {error_msg}")
490
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  elif status in ["queued", "processing"]:
492
  # Solo esperar, el spinner ya muestra que está procesando
493
  time.sleep(5) # Esperar 5 segundos antes del siguiente polling
@@ -698,8 +720,27 @@ if page == "Processar vídeo nou":
698
  st.markdown("---")
699
  st.markdown("### 🎬 Generar audiodescripció")
700
  if st.button("🎬 Generar Audiodescripció", type="primary", use_container_width=True):
701
- st.info("🚧 Funcionalitat en desenvolupament...")
702
- # Aquí iría la lógica para generar la audiodescripción
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
 
704
  elif page == "Analitzar audio-descripcions":
705
  require_login()
 
445
  import time
446
  max_attempts = 60 # 5 minutos máximo (5 segundos * 60)
447
  attempt = 0
448
+ retried_new_job = False
449
 
450
  with message_placeholder:
451
  with st.spinner("⏳ Detectant personatges... Això pot trigar uns minuts."):
 
489
  log(f"✗ Job falló: {error_msg}")
490
  st.error(f"❌ Error en el processament: {error_msg}")
491
  break
492
+ elif status == "not_found":
493
+ # El servidor puede haber reiniciado; intentamos recrear una sola vez
494
+ if not retried_new_job:
495
+ log("Status not_found: reintentant crear el job un cop més…")
496
+ resp2 = api.create_initial_casting(
497
+ video_bytes=video_bytes,
498
+ video_name=video_name,
499
+ epsilon=st.session_state.get("epsilon_slider", epsilon),
500
+ min_cluster_size=int(st.session_state.get("min_cluster_slider", min_cluster_size)),
501
+ )
502
+ if isinstance(resp2, dict) and resp2.get("job_id"):
503
+ job_id = resp2["job_id"]
504
+ retried_new_job = True
505
+ attempt = 0
506
+ continue
507
+ else:
508
+ st.error("No s'ha pogut recrear el job després d'un 404 del servidor.")
509
+ break
510
+ else:
511
+ st.error("El job no existeix al servidor (404) després d'un reintent.")
512
+ break
513
  elif status in ["queued", "processing"]:
514
  # Solo esperar, el spinner ya muestra que está procesando
515
  time.sleep(5) # Esperar 5 segundos antes del siguiente polling
 
720
  st.markdown("---")
721
  st.markdown("### 🎬 Generar audiodescripció")
722
  if st.button("🎬 Generar Audiodescripció", type="primary", use_container_width=True):
723
+ video_info = st.session_state.get('video_uploaded') or {}
724
+ video_bytes = video_info.get('video_bytes')
725
+ video_name = video_info.get('original_name') or video_info.get('video_name') or "video.mp4"
726
+ if not video_bytes:
727
+ st.error("No s'ha trobat el vídeo en memòria.")
728
+ else:
729
+ with st.spinner("Generant audiodescripció (pot tardar la primera vegada pels models)..."):
730
+ resp = api.generate_audiodescription(video_bytes=video_bytes, video_name=video_name)
731
+ if isinstance(resp, dict) and resp.get('error'):
732
+ st.error(f"Error: {resp['error']}")
733
+ else:
734
+ results = (resp or {}).get('results', {})
735
+ une_srt = results.get('une_srt', '')
736
+ free_text = results.get('free_text', '')
737
+ st.success("✅ Audiodescripció generada")
738
+ with st.expander("UNE-153010 (SRT)", expanded=True):
739
+ st.text_area("Contingut SRT:", value=une_srt, height=250)
740
+ st.download_button("Descarregar .srt", data=une_srt, file_name="une_ad.srt", mime="application/x-subrip")
741
+ with st.expander("Narració lliure (text)"):
742
+ st.text_area("Text lliure:", value=free_text, height=250)
743
+ st.download_button("Descarregar .txt", data=free_text, file_name="free_ad.txt", mime="text/plain")
744
 
745
  elif page == "Analitzar audio-descripcions":
746
  require_login()