VeuReu commited on
Commit
ac342d0
·
verified ·
1 Parent(s): 7e3f07e

Update api_client.py

Browse files
Files changed (1) hide show
  1. api_client.py +332 -303
api_client.py CHANGED
@@ -1,303 +1,332 @@
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}/result"
46
- r = self.session.get(url, timeout=self.timeout)
47
- r.raise_for_status()
48
- return r.json() # JobResult (book/une/... 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: si quieres 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
- res = self._get_result(job_id)
79
- # 'res' viene como JobResult del engine: {"book": {...}, "une": {...}, ...}
80
- # La UI consume 'results' con claves "book"/"une"; si tus claves ya son iguales, pasa directo:
81
- results = {}
82
- if "book" in res:
83
- results["book"] = {
84
- "text": res["book"].get("text"),
85
- # si sirves URLs en el engine, podrías mapear "book_mp3_url" a descarga directa;
86
- # la UI actual espera "mp3_bytes" sólo en mock, así que lo dejamos fuera.
87
- }
88
- if "une" in res:
89
- results["une"] = {
90
- "srt": res["une"].get("srt"),
91
- }
92
- # Si res incluye "characters"/"metrics", la UI también los guarda:
93
- for k in ("book", "une"):
94
- if k in res:
95
- if "characters" in res[k]:
96
- results[k]["characters"] = res[k]["characters"]
97
- if "metrics" in res[k]:
98
- results[k]["metrics"] = res[k]["metrics"]
99
-
100
- status = "done" if results else st.get("status", "unknown")
101
- return {"status": status, "results": results}
102
-
103
-
104
- def tts_matxa(self, text: str, voice: str = "central/grau") -> dict:
105
- """
106
- Llama al space 'tts' para sintetizar audio.
107
-
108
- Args:
109
- text (str): Texto a sintetizar.
110
- voice (str): Voz de Matxa a usar (p.ej. 'central/alvocat').
111
-
112
- Returns:
113
- dict: {'mp3_data_url': 'data:audio/mpeg;base64,...'}
114
- """
115
- if not self.tts_url:
116
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
117
-
118
- url = f"{self.tts_url.rstrip('/')}/tts/text"
119
- data = {
120
- "texto": text,
121
- "voice": voice,
122
- "formato": "mp3"
123
- }
124
-
125
- try:
126
- r = requests.post(url, data=data, timeout=self.timeout)
127
- r.raise_for_status()
128
-
129
- # Devolver los bytes directamente para que el cliente los pueda concatenar
130
- return {"mp3_bytes": r.content}
131
-
132
- except requests.exceptions.RequestException as e:
133
- print(f"Error cridant a TTS: {e}")
134
- # Devolvemos un diccionario con error para que la UI lo muestre
135
- return {"error": str(e)}
136
-
137
-
138
- def rebuild_video_with_ad(self, video_path: str, srt_path: str) -> dict:
139
- """
140
- Llama al space 'tts' para reconstruir un vídeo con audiodescripció a partir de un SRT.
141
- El servidor devuelve un ZIP, y de ahí extraemos el MP4 final.
142
- """
143
- if not self.tts_url:
144
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
145
-
146
- url = f"{self.tts_url.rstrip('/')}/tts/srt"
147
-
148
- try:
149
- files = {
150
- 'video': (os.path.basename(video_path), open(video_path, 'rb'), 'video/mp4'),
151
- 'srt': (os.path.basename(srt_path), open(srt_path, 'rb'), 'application/x-subrip')
152
- }
153
- data = {"include_final_mp4": 1}
154
-
155
- r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
156
- r.raise_for_status()
157
-
158
- # El servidor devuelve un ZIP, lo procesamos en memoria
159
- with zipfile.ZipFile(io.BytesIO(r.content)) as z:
160
- # Buscamos el archivo .mp4 dentro del ZIP
161
- for filename in z.namelist():
162
- if filename.endswith('.mp4'):
163
- video_bytes = z.read(filename)
164
- return {"video_bytes": video_bytes}
165
-
166
- # Si no se encuentra el MP4 en el ZIP
167
- return {"error": "No se encontró el archivo de vídeo MP4 en la respuesta del servidor."}
168
-
169
- except requests.exceptions.RequestException as e:
170
- print(f"Error cridant a la reconstrucció de vídeo: {e}")
171
- return {"error": str(e)}
172
- except zipfile.BadZipFile:
173
- return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
174
-
175
-
176
- 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:
177
- """
178
- Llama al endpoint del space 'engine' para crear el 'initial casting'.
179
-
180
- Envía el vídeo recién importado como archivo y los parámetros de clustering.
181
-
182
- Args:
183
- video_path: Path to video file (if reading from disk)
184
- video_bytes: Video file bytes (if already in memory)
185
- video_name: Name for the video file
186
- epsilon: Clustering epsilon parameter
187
- min_cluster_size: Minimum cluster size parameter
188
- """
189
- url = f"{self.base_url}/create_initial_casting"
190
- try:
191
- # Prepare file data
192
- if video_bytes:
193
- filename = video_name or "video.mp4"
194
- files = {
195
- "video": (filename, video_bytes, "video/mp4"),
196
- }
197
- elif video_path:
198
- with open(video_path, "rb") as f:
199
- files = {
200
- "video": (os.path.basename(video_path), f.read(), "video/mp4"),
201
- }
202
- else:
203
- return {"error": "Either video_path or video_bytes must be provided"}
204
-
205
- data = {
206
- "epsilon": str(epsilon),
207
- "min_cluster_size": str(min_cluster_size),
208
- }
209
- r = self.session.post(url, files=files, data=data, timeout=self.timeout * 5)
210
- r.raise_for_status()
211
- return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
212
- except requests.exceptions.RequestException as e:
213
- return {"error": str(e)}
214
- except Exception as e:
215
- return {"error": f"Unexpected error: {str(e)}"}
216
-
217
- def generate_audio_from_text_file(self, text_content: str, voice: str = "central/grau") -> dict:
218
- """
219
- Genera un único MP3 a partir de un texto largo, usando el endpoint de SRT.
220
- 1. Convierte el texto en un SRT falso.
221
- 2. Llama a /tts/srt con el SRT.
222
- 3. Extrae el 'ad_master.mp3' del ZIP resultante.
223
- """
224
- if not self.tts_url:
225
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
226
-
227
- # 1. Crear un SRT falso en memoria
228
- srt_content = ""
229
- start_time = 0
230
- for i, line in enumerate(text_content.strip().split('\n')):
231
- line = line.strip()
232
- if not line:
233
- continue
234
- # Asignar 5 segundos por línea, un valor simple
235
- end_time = start_time + 5
236
-
237
- def format_time(seconds):
238
- h = int(seconds / 3600)
239
- m = int((seconds % 3600) / 60)
240
- s = int(seconds % 60)
241
- ms = int((seconds - int(seconds)) * 1000)
242
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
243
-
244
- srt_content += f"{i+1}\n"
245
- srt_content += f"{format_time(start_time)} --> {format_time(end_time)}\n"
246
- srt_content += f"{line}\n\n"
247
- start_time = end_time
248
-
249
- if not srt_content:
250
- return {"error": "El texto proporcionado estaba vacío o no se pudo procesar."}
251
-
252
- # 2. Llamar al endpoint /tts/srt
253
- url = f"{self.tts_url.rstrip('/')}/tts/srt"
254
- try:
255
- files = {
256
- 'srt': ('fake_ad.srt', srt_content, 'application/x-subrip')
257
- }
258
- data = {"voice": voice, "ad_format": "mp3"}
259
-
260
- r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
261
- r.raise_for_status()
262
-
263
- # 3. Extraer 'ad_master.mp3' del ZIP
264
- with zipfile.ZipFile(io.BytesIO(r.content)) as z:
265
- for filename in z.namelist():
266
- if filename == 'ad_master.mp3':
267
- mp3_bytes = z.read(filename)
268
- return {"mp3_bytes": mp3_bytes}
269
-
270
- return {"error": "No se encontró 'ad_master.mp3' en la respuesta del servidor."}
271
-
272
- except requests.exceptions.RequestException as e:
273
- return {"error": f"Error llamando a la API de SRT: {e}"}
274
- except zipfile.BadZipFile:
275
- return {"error": "La respuesta del servidor no fue un archivo ZIP válido."}
276
-
277
-
278
- def tts_long_text(self, text: str, voice: str = "central/grau") -> dict:
279
- """
280
- Llama al endpoint '/tts/text_long' para sintetizar un texto largo.
281
- La API se encarga de todo el procesamiento.
282
- """
283
- if not self.tts_url:
284
- raise ValueError("La URL del servei TTS no està configurada (API_TTS_URL)")
285
-
286
- url = f"{self.tts_url.rstrip('/')}/tts/text_long"
287
- data = {
288
- "texto": text,
289
- "voice": voice,
290
- "formato": "mp3"
291
- }
292
-
293
- try:
294
- # Usamos un timeout más largo por si el texto es muy extenso
295
- r = requests.post(url, data=data, timeout=self.timeout * 10)
296
- r.raise_for_status()
297
- return {"mp3_bytes": r.content}
298
-
299
- except requests.exceptions.RequestException as e:
300
- print(f"Error cridant a TTS per a text llarg: {e}")
301
- return {"error": str(e)}
302
-
303
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api_client.py (UI - Space "veureu")
2
+
3
+ import os
4
+ import io
5
+ import base64
6
+ import zipfile
7
+ import requests
8
+ from typing import Iterable, Dict, Any
9
+
10
+
11
+ class APIClient:
12
+ """
13
+ High-level client for communicating with the Veureu Engine API.
14
+
15
+ Endpoints managed:
16
+ POST /jobs
17
+ {"job_id": "..."}
18
+
19
+ GET /jobs/{job_id}/status
20
+ {"status": "queued|processing|done|failed", ...}
21
+
22
+ GET /jobs/{job_id}/result
23
+ JobResult such as {"book": {...}, "une": {...}, ...}
24
+
25
+ This class is used by the Streamlit UI to submit videos, poll job status,
26
+ retrieve results, generate audio, and interact with the TTS and casting services.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ base_url: str,
32
+ use_mock: bool = False,
33
+ data_dir: str | None = None,
34
+ token: str | None = None,
35
+ timeout: int = 180
36
+ ):
37
+ """
38
+ Initialize the API client.
39
+
40
+ Args:
41
+ base_url: Base URL of the engine or TTS service.
42
+ use_mock: Whether to respond with mock data instead of real API calls.
43
+ data_dir: Optional data folder for local mock/test files.
44
+ token: Authentication token (fallback: API_SHARED_TOKEN env var).
45
+ timeout: Timeout in seconds for requests.
46
+ """
47
+ self.base_url = base_url.rstrip("/")
48
+ self.tts_url = self.base_url # For HF Spaces, TTS lives at same base URL
49
+ self.use_mock = use_mock
50
+ self.data_dir = data_dir
51
+ self.timeout = timeout
52
+ self.session = requests.Session()
53
+
54
+ # Authorization header if token provided
55
+ token = token or os.getenv("API_SHARED_TOKEN")
56
+ if token:
57
+ self.session.headers.update({"Authorization": f"Bearer {token}"})
58
+
59
+
60
+ # -------------------------------------------------------------------------
61
+ # Internal engine calls
62
+ # -------------------------------------------------------------------------
63
+
64
+ def _post_jobs(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
65
+ """Submit a video and processing modes to /jobs."""
66
+ url = f"{self.base_url}/jobs"
67
+ files = {
68
+ "file": (os.path.basename(video_path), open(video_path, "rb"), "application/octet-stream")
69
+ }
70
+ data = {"modes": ",".join(modes)}
71
+
72
+ r = self.session.post(url, files=files, data=data, timeout=self.timeout)
73
+ r.raise_for_status()
74
+ return r.json()
75
+
76
+ def _get_status(self, job_id: str) -> Dict[str, Any]:
77
+ """Query job status."""
78
+ url = f"{self.base_url}/jobs/{job_id}/status"
79
+ r = self.session.get(url, timeout=self.timeout)
80
+ r.raise_for_status()
81
+ return r.json()
82
+
83
+ def _get_result(self, job_id: str) -> Dict[str, Any]:
84
+ """Retrieve job result."""
85
+ url = f"{self.base_url}/jobs/{job_id}/result"
86
+ r = self.session.get(url, timeout=self.timeout)
87
+ r.raise_for_status()
88
+ return r.json()
89
+
90
+
91
+ # -------------------------------------------------------------------------
92
+ # Public API used by streamlit_app.py
93
+ # -------------------------------------------------------------------------
94
+
95
+ def process_video(self, video_path: str, modes: Iterable[str]) -> Dict[str, Any]:
96
+ """Return {"job_id": "..."} either from mock or engine."""
97
+ if self.use_mock:
98
+ return {"job_id": "mock-123"}
99
+ return self._post_jobs(video_path, modes)
100
+
101
+ def get_job(self, job_id: str) -> Dict[str, Any]:
102
+ """
103
+ Returns UI-friendly job data:
104
+ {"status": "done", "results": {"book": {...}, "une": {...}}}
105
+
106
+ Maps engine responses into the expected 'results' format.
107
+ """
108
+ if self.use_mock:
109
+ return {
110
+ "status": "done",
111
+ "results": {
112
+ "book": {"text": "Example text (book)", "mp3_bytes": b""},
113
+ "une": {
114
+ "srt": "1\n00:00:00,000 --> 00:00:01,000\nExample UNE\n",
115
+ "mp3_bytes": b""
116
+ }
117
+ }
118
+ }
119
+
120
+ status_data = self._get_status(job_id)
121
+
122
+ # If still processing, return minimal structure
123
+ if status_data.get("status") in {"queued", "processing"}:
124
+ return {"status": status_data.get("status", "queued")}
125
+
126
+ raw_result = self._get_result(job_id)
127
+ results = {}
128
+
129
+ # Direct mapping of book/une sections
130
+ if "book" in raw_result:
131
+ results["book"] = {"text": raw_result["book"].get("text")}
132
+ if "une" in raw_result:
133
+ results["une"] = {"srt": raw_result["une"].get("srt")}
134
+
135
+ # Preserve characters/metrics if present
136
+ for section in ("book", "une"):
137
+ if section in raw_result:
138
+ if "characters" in raw_result[section]:
139
+ results[section]["characters"] = raw_result[section]["characters"]
140
+ if "metrics" in raw_result[section]:
141
+ results[section]["metrics"] = raw_result[section]["metrics"]
142
+
143
+ final_status = "done" if results else status_data.get("status", "unknown")
144
+ return {"status": final_status, "results": results}
145
+
146
+
147
+ # -------------------------------------------------------------------------
148
+ # TTS Services
149
+ # -------------------------------------------------------------------------
150
+
151
+ def tts_matxa(self, text: str, voice: str = "central/grau") -> dict:
152
+ """
153
+ Call the TTS /tts/text endpoint to synthesize short audio.
154
+
155
+ Returns:
156
+ {"mp3_bytes": b"..."} on success
157
+ {"error": "..."} on failure
158
+ """
159
+ if not self.tts_url:
160
+ raise ValueError("TTS service URL not configured.")
161
+
162
+ url = f"{self.tts_url.rstrip('/')}/tts/text"
163
+ data = {"texto": text, "voice": voice, "formato": "mp3"}
164
+
165
+ try:
166
+ r = requests.post(url, data=data, timeout=self.timeout)
167
+ r.raise_for_status()
168
+ return {"mp3_bytes": r.content}
169
+ except requests.exceptions.RequestException as e:
170
+ return {"error": str(e)}
171
+
172
+ def rebuild_video_with_ad(self, video_path: str, srt_path: str) -> dict:
173
+ """
174
+ Rebuild a video including audio description (AD)
175
+ by calling /tts/srt. The server returns a ZIP containing an MP4.
176
+ """
177
+ if not self.tts_url:
178
+ raise ValueError("TTS service URL not configured.")
179
+
180
+ url = f"{self.tts_url.rstrip('/')}/tts/srt"
181
+
182
+ try:
183
+ files = {
184
+ "video": (os.path.basename(video_path), open(video_path, "rb"), "video/mp4"),
185
+ "srt": (os.path.basename(srt_path), open(srt_path, "rb"), "application/x-subrip")
186
+ }
187
+ data = {"include_final_mp4": 1}
188
+
189
+ r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
190
+ r.raise_for_status()
191
+
192
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
193
+ for name in z.namelist():
194
+ if name.endswith(".mp4"):
195
+ return {"video_bytes": z.read(name)}
196
+
197
+ return {"error": "MP4 file not found inside ZIP."}
198
+
199
+ except zipfile.BadZipFile:
200
+ return {"error": "Invalid ZIP response from server."}
201
+ except requests.exceptions.RequestException as e:
202
+ return {"error": str(e)}
203
+
204
+
205
+ # -------------------------------------------------------------------------
206
+ # Engine casting services
207
+ # -------------------------------------------------------------------------
208
+
209
+ def create_initial_casting(
210
+ self,
211
+ video_path: str = None,
212
+ video_bytes: bytes = None,
213
+ video_name: str = None,
214
+ epsilon: float = 0.5,
215
+ min_cluster_size: int = 2
216
+ ) -> dict:
217
+ """
218
+ Calls /create_initial_casting to produce the initial actor/face clustering.
219
+
220
+ Args:
221
+ video_path: Load video from disk.
222
+ video_bytes: Provide video already in memory.
223
+ video_name: Name used if video_bytes is provided.
224
+ epsilon: DBSCAN epsilon for clustering.
225
+ min_cluster_size: Minimum number of samples for DBSCAN.
226
+ """
227
+ url = f"{self.base_url}/create_initial_casting"
228
+
229
+ try:
230
+ # Prepare video input
231
+ if video_bytes:
232
+ files = {"video": (video_name or "video.mp4", video_bytes, "video/mp4")}
233
+ elif video_path:
234
+ with open(video_path, "rb") as f:
235
+ files = {"video": (os.path.basename(video_path), f.read(), "video/mp4")}
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
+
244
+ r = self.session.post(url, files=files, data=data, timeout=self.timeout * 5)
245
+ r.raise_for_status()
246
+
247
+ if r.headers.get("content-type", "").startswith("application/json"):
248
+ return r.json()
249
+
250
+ return {"ok": True}
251
+
252
+ except Exception as e:
253
+ return {"error": str(e)}
254
+
255
+
256
+ # -------------------------------------------------------------------------
257
+ # Long text TTS helpers
258
+ # -------------------------------------------------------------------------
259
+
260
+ def generate_audio_from_text_file(self, text_content: str, voice: str = "central/grau") -> dict:
261
+ """
262
+ Converts a large text into an SRT-like structure, calls /tts/srt,
263
+ and extracts 'ad_master.mp3' from the resulting ZIP.
264
+
265
+ Useful for audiobook-like generation.
266
+ """
267
+ if not self.tts_url:
268
+ raise ValueError("TTS service URL not configured.")
269
+
270
+ # Build synthetic SRT in memory
271
+ srt_content = ""
272
+ start = 0
273
+
274
+ for idx, raw_line in enumerate(text_content.strip().split("\n")):
275
+ line = raw_line.strip()
276
+ if not line:
277
+ continue
278
+
279
+ end = start + 5 # simplistic 5 seconds per subtitle
280
+
281
+ def fmt(seconds):
282
+ h = seconds // 3600
283
+ m = (seconds % 3600) // 60
284
+ s = seconds % 60
285
+ return f"{h:02d}:{m:02d}:{s:02d},000"
286
+
287
+ srt_content += f"{idx+1}\n"
288
+ srt_content += f"{fmt(start)} --> {fmt(end)}\n"
289
+ srt_content += f"{line}\n\n"
290
+ start = end
291
+
292
+ if not srt_content:
293
+ return {"error": "Provided text is empty or cannot be processed."}
294
+
295
+ # Call server
296
+ url = f"{self.tts_url.rstrip('/')}/tts/srt"
297
+
298
+ try:
299
+ files = {"srt": ("fake_ad.srt", srt_content, "application/x-subrip")}
300
+ data = {"voice": voice, "ad_format": "mp3"}
301
+
302
+ r = requests.post(url, files=files, data=data, timeout=self.timeout * 5)
303
+ r.raise_for_status()
304
+
305
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
306
+ if "ad_master.mp3" in z.namelist():
307
+ return {"mp3_bytes": z.read("ad_master.mp3")}
308
+
309
+ return {"error": "'ad_master.mp3' not found inside ZIP."}
310
+
311
+ except requests.exceptions.RequestException as e:
312
+ return {"error": f"Error calling SRT API: {e}"}
313
+ except zipfile.BadZipFile:
314
+ return {"error": "Invalid ZIP response from server."}
315
+
316
+ def tts_long_text(self, text: str, voice: str = "central/grau") -> dict:
317
+ """
318
+ Call /tts/text_long for very long text TTS synthesis.
319
+ Returns raw MP3 bytes.
320
+ """
321
+ if not self.tts_url:
322
+ raise ValueError("TTS service URL not configured.")
323
+
324
+ url = f"{self.tts_url.rstrip('/')}/tts/text_long"
325
+ data = {"texto": text, "voice": voice, "formato": "mp3"}
326
+
327
+ try:
328
+ r = requests.post(url, data=data, timeout=self.timeout * 10)
329
+ r.raise_for_status()
330
+ return {"mp3_bytes": r.content}
331
+ except requests.exceptions.RequestException as e:
332
+ return {"error": str(e)}