VeuReu commited on
Commit
04a4cfc
·
verified ·
1 Parent(s): 287377b

Upload 3 files

Browse files
Files changed (3) hide show
  1. api_client.py +303 -0
  2. app.py +705 -0
  3. test_connection.py +69 -0
api_client.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
app.py ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ import yaml
5
+ import shutil
6
+ import sqlite3
7
+ import sys
8
+ from pathlib import Path
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError: # Py<3.11
12
+ import tomli as tomllib
13
+ import streamlit as st
14
+ # from moviepy.editor import VideoFileClip
15
+
16
+ from database import set_db_path, init_schema, get_user, create_user, update_user_password, create_video, update_video_status, list_videos, get_video, get_all_users, upsert_result, get_results, add_feedback, get_feedback_for_video, get_feedback_stats
17
+ from api_client import APIClient
18
+ from utils import ensure_dirs, save_bytes, save_text, human_size, get_project_root
19
+
20
+ from scripts.client_generate_av import generate_free_ad_mp3, generate_une_ad_video
21
+
22
+ # --- Rutas y Configuración Inicial ---
23
+ PROJECT_ROOT = get_project_root()
24
+
25
+ # Copia de seguridad de la base de datos y vídeos a un directorio escribible en HF Spaces
26
+ if os.getenv("SPACE_ID") is not None:
27
+ os.environ["STREAMLIT_DATA_DIRECTORY"] = "/tmp/.streamlit"
28
+ Path("/tmp/.streamlit").mkdir(parents=True, exist_ok=True)
29
+
30
+ # Limpiar archivos temporales antiguos para evitar llenar el disco
31
+ import glob
32
+ import time
33
+ temp_files = glob.glob("/tmp/*")
34
+ current_time = time.time()
35
+ for f in temp_files:
36
+ try:
37
+ if os.path.isfile(f) and current_time - os.path.getmtime(f) > 3600: # Archivos > 1 hora
38
+ os.remove(f)
39
+ print(f"Archivo temporal eliminado: {f}")
40
+ except Exception as e:
41
+ pass # Ignorar errores de permisos
42
+
43
+
44
+ # static_videos = Path(__file__).parent / "videos"
45
+ # runtime_videos = PROJECT_ROOT / "videos"
46
+ # if not runtime_videos.exists():
47
+ # shutil.copytree(static_videos, runtime_videos, dirs_exist_ok=True)
48
+
49
+ # --- Config ---
50
+ def _load_yaml(path="config.yaml") -> dict:
51
+ with open(path, "r", encoding="utf-8") as f:
52
+ cfg = yaml.safe_load(f) or {}
53
+ # interpolación sencilla de ${VARS} si las usas en el YAML
54
+ def _subst(s: str) -> str:
55
+ return os.path.expandvars(s) if isinstance(s, str) else s
56
+
57
+ # aplica sustitución en los campos que te interesan
58
+ if "api" in cfg:
59
+ cfg["api"]["base_url"] = _subst(cfg["api"].get("base_url", ""))
60
+ cfg["api"]["token"] = _subst(cfg["api"].get("token", ""))
61
+
62
+ if "storage" in cfg and "root_dir" in cfg["storage"]:
63
+ cfg["storage"]["root_dir"] = _subst(cfg["storage"]["root_dir"])
64
+
65
+ if "sqlite" in cfg and "path" in cfg["sqlite"]:
66
+ cfg["sqlite"]["path"] = _subst(cfg["sqlite"]["path"])
67
+
68
+ return cfg
69
+
70
+ CFG = _load_yaml("config.yaml")
71
+
72
+ # Ajuste de variables según tu esquema YAML
73
+ DATA_DIR = CFG.get("storage", {}).get("root_dir", "data")
74
+ BACKEND_BASE_URL = CFG.get("api", {}).get("base_url", "http://localhost:8000")
75
+ USE_MOCK = bool(CFG.get("app", {}).get("use_mock", False)) # si no la tienes en el yaml, queda False
76
+ API_TOKEN = CFG.get("api", {}).get("token") or os.getenv("API_SHARED_TOKEN")
77
+
78
+ os.makedirs(DATA_DIR, exist_ok=True)
79
+ ensure_dirs(DATA_DIR)
80
+ DB_PATH = os.path.join(DATA_DIR, "app.db")
81
+ set_db_path(DB_PATH)
82
+
83
+
84
+ init_schema()
85
+
86
+ # --- Helper de logging ---
87
+ def log(msg):
88
+ """Helper para escribir logs que aparezcan en el container de HF Spaces"""
89
+ sys.stderr.write(f"{msg}\n")
90
+ sys.stderr.flush()
91
+
92
+ def create_default_users_if_needed():
93
+ """Asegura que existan los usuarios por defecto y sus contraseñas esperadas (texto plano)."""
94
+ log("Sincronizando usuarios por defecto...")
95
+ users_to_create = [
96
+ ("verd", "verd123", "verd"),
97
+ ("groc", "groc123", "groc"),
98
+ ("taronja", "taronja123", "taronja"),
99
+ ("blau", "blau123", "blau"),
100
+ ]
101
+ for username, password, role in users_to_create:
102
+ try:
103
+ row = get_user(username)
104
+ if row:
105
+ update_user_password(username, password)
106
+ log(f"Usuario '{username}' actualizado (password reset).")
107
+ else:
108
+ create_user(username, password, role)
109
+ log(f"Usuario '{username}' creado.")
110
+ except Exception as e:
111
+ log(f"Error sincronizando usuario {username}: {e}")
112
+
113
+ create_default_users_if_needed()
114
+
115
+ # --- LOG DE DIAGNÓSTICO: COMPROBAR ESTADO DE LA BD ---
116
+
117
+ log("\n--- DIAGNÓSTICO DE BASE DE DATOS ---")
118
+ log(f"Ruta de la BD en uso: {DB_PATH}")
119
+ try:
120
+ all_users = get_all_users()
121
+ if all_users:
122
+ log("Usuarios encontrados en la BD al arrancar:")
123
+ # Convertimos las filas a diccionarios para una mejor visualización
124
+ users_list = [dict(user) for user in all_users]
125
+ log(str(users_list))
126
+ else:
127
+ log("La tabla de usuarios está vacía.")
128
+ except Exception as e:
129
+ log(f"Error al intentar leer los usuarios de la BD: {e}")
130
+ log("--- FIN DIAGNÓSTICO ---\n")
131
+ # --- FIN LOG DE DIAGNÓSTICO ---
132
+
133
+ api = APIClient(BACKEND_BASE_URL, use_mock=USE_MOCK, data_dir=DATA_DIR, token=API_TOKEN)
134
+
135
+ # Diagnóstico de configuración de API
136
+ log(f"\n--- CONFIGURACIÓN DE API ---")
137
+ log(f"BACKEND_BASE_URL: {BACKEND_BASE_URL}")
138
+ log(f"API_TOKEN configurado: {'Sí' if API_TOKEN else 'No'}")
139
+ log(f"USE_MOCK: {USE_MOCK}")
140
+ log(f"--- FIN CONFIGURACIÓN ---\n")
141
+
142
+ st.set_page_config(page_title="Veureu — Audiodescripció", page_icon="🎬", layout="wide")
143
+
144
+ # Configurar Streamlit para aceptar archivos más grandes
145
+ try:
146
+ import streamlit.web.server.server as server
147
+ # Aumentar el límite de tamaño de archivo
148
+ server.UPLOAD_FILE_SIZE_LIMIT = 50 * 1024 * 1024 # 50MB
149
+ log("Límite de subida configurado a 50MB en server")
150
+ except Exception as e:
151
+ log(f"No se pudo configurar límite de subida en server: {e}")
152
+
153
+ # Verificar configuración actual
154
+ try:
155
+ import streamlit.config as st_config
156
+ max_upload = st_config.get_option("server.maxUploadSize")
157
+ log(f"Configuración actual maxUploadSize: {max_upload}MB")
158
+ except Exception as e:
159
+ log(f"No se pudo leer configuración: {e}")
160
+
161
+ # --- Session: auth ---
162
+ # print("Usuarios disponibles:", get_all_users()) # Descomentar para depurar
163
+ if "user" not in st.session_state:
164
+ st.session_state.user = None # dict with {username, role, id(optional)}
165
+
166
+ def require_login():
167
+ if not st.session_state.user:
168
+ st.info("Por favor, inicia sesión para continuar.")
169
+ login_form()
170
+ st.stop()
171
+
172
+ def verify_password(password: str, stored_password: str) -> bool:
173
+ """Verifica la contraseña como texto plano."""
174
+ return password == stored_password
175
+
176
+ # --- Sidebar (only after login) ---
177
+ role = st.session_state.user["role"] if st.session_state.user else None
178
+ with st.sidebar:
179
+ st.title("Veureu")
180
+ if st.session_state.user:
181
+ st.write(f"Usuari: **{st.session_state.user['username']}** (rol: {st.session_state.user['role']})")
182
+ if st.button("Tancar sessió"):
183
+ st.session_state.user = None
184
+ st.rerun()
185
+ if st.session_state.user:
186
+ page = st.radio("Navegació", ["Analitzar audio-descripcions","Processar vídeo nou","Estadístiques"], index=0)
187
+ log(f"Página seleccionada: {page}")
188
+ else:
189
+ page = None
190
+
191
+ # --- Pre-login screen ---
192
+ if not st.session_state.user:
193
+ st.title("Veureu — Audiodescripció")
194
+ def login_form():
195
+ st.subheader("Inici de sessió")
196
+ username = st.text_input("Usuari")
197
+ password = st.text_input("Contrasenya", type="password")
198
+ if st.button("Entrar", type="primary"):
199
+ row = get_user(username)
200
+
201
+ # --- LOGS DE DEPURACIÓN ---
202
+ log("\n--- INTENTO DE LOGIN ---")
203
+ log(f"Usuario introducido: '{username}'")
204
+ # No mostramos la contraseña por seguridad, pero confirmamos que no está vacía
205
+ log(f"Contraseña introducida: {'Sí' if password else 'No'}")
206
+
207
+ if row:
208
+ log(f"Usuario encontrado en BD: '{row['username']}'")
209
+ stored_pw = (row["password_hash"] or "")
210
+ log(f"Password almacenado (longitud): {len(stored_pw)}")
211
+ is_valid = verify_password(password, stored_pw)
212
+ log(f"Resultado de verify_password: {is_valid}")
213
+ else:
214
+ log("Usuario no encontrado en la BD.")
215
+ is_valid = False
216
+
217
+ log("--- FIN INTENTO DE LOGIN ---\n")
218
+ # --- FIN LOGS DE DEPURACIÓN ---
219
+
220
+ if is_valid:
221
+ st.session_state.user = {"id": row["id"], "username": row["username"], "role": row["role"]}
222
+ st.success(f"Benvingut/da, {row['username']}")
223
+ st.rerun()
224
+ else:
225
+ st.error("Credencials invàlides")
226
+ login_form()
227
+ st.stop()
228
+
229
+ # --- Pages ---
230
+ if page == "Processar vídeo nou":
231
+ log("\n=== ACCESO A PÁGINA 'Processar vídeo nou' ===")
232
+ require_login()
233
+ if role != "verd":
234
+ log("ERROR: Usuario sin permisos para procesar vídeos")
235
+ st.error("No tens permisos per processar nous vídeos. Canvia d'usuari o sol·licita permisos.")
236
+ st.stop()
237
+
238
+ log("Usuario autorizado, mostrando interfaz de subida")
239
+ st.header("Processar un nou clip de vídeo")
240
+
241
+ # Inicializar el estado de la página si no existe
242
+ if 'video_uploaded' not in st.session_state:
243
+ st.session_state.video_uploaded = None
244
+ log("Estado 'video_uploaded' inicializado")
245
+ if 'characters_detected' not in st.session_state:
246
+ st.session_state.characters_detected = None
247
+ log("Estado 'characters_detected' inicializado")
248
+ if 'characters_saved' not in st.session_state:
249
+ st.session_state.characters_saved = False
250
+ log("Estado 'characters_saved' inicializado")
251
+
252
+ # --- 1. Subida del vídeo ---
253
+ MAX_SIZE_MB = 10 # Reducido a 10MB para evitar llenar el disco en HF Spaces
254
+ MAX_DURATION_S = 120 # Reducido a 2 minutos
255
+
256
+ log("Mostrando widget de subida de archivo...")
257
+
258
+ # Verificar configuración de Streamlit
259
+ import streamlit.config as st_config
260
+ try:
261
+ max_size = st_config.get_option("server.maxUploadSize")
262
+ log(f"Configuración maxUploadSize: {max_size}MB")
263
+ except Exception as e:
264
+ log(f"No se pudo leer maxUploadSize: {e}")
265
+
266
+ uploaded_file = st.file_uploader(
267
+ "Puja un clip de vídeo (MP4, < 10MB, < 2 minuts)",
268
+ type=["mp4"],
269
+ key="video_uploader",
270
+ help="Selecciona un archivo MP4 de tu dispositivo. Máximo 10MB."
271
+ )
272
+ log(f"Widget renderizado. Archivo subido: {uploaded_file is not None}")
273
+
274
+ # Debug: Mostrar información del archivo si existe
275
+ if uploaded_file is not None:
276
+ log(f"¡ARCHIVO DETECTADO! Nombre: {uploaded_file.name}, Tamaño: {uploaded_file.size}")
277
+ else:
278
+ log("No hay archivo subido todavía")
279
+ # Mostrar mensaje de ayuda en la interfaz
280
+ st.info("ℹ️ **Instrucciones:**\n1. Haz clic en 'Browse files'\n2. Selecciona un archivo MP4 (< 10MB)\n3. Espera a que se cargue (puede tardar unos segundos)")
281
+ st.warning("⚠️ **Nota**: Si el archivo no se detecta después de seleccionarlo, puede haber un problema con la configuración de Hugging Face Spaces.")
282
+
283
+ # Mostrar estado de depuración solo si no hay archivo o si hay un problema
284
+ if uploaded_file is None and st.session_state.video_uploaded is None:
285
+ with st.expander("🔍 Debug Info", expanded=False):
286
+ st.write(f"**Archivo subido:** {uploaded_file is not None}")
287
+ st.info("ℹ️ No se ha seleccionado ningún archivo todavía")
288
+ st.write(f"**Estado video_uploaded:** {st.session_state.video_uploaded}")
289
+
290
+ if uploaded_file is not None:
291
+ log(f"\n--- SUBIDA DE VÍDEO INICIADA (archivo detectado) ---")
292
+ log(f"Nombre del archivo: {uploaded_file.name}")
293
+ log(f"Tamaño del archivo: {uploaded_file.size} bytes ({uploaded_file.size / (1024*1024):.2f} MB)")
294
+ log(f"Tipo MIME: {uploaded_file.type}")
295
+
296
+ # Resetear el estado si se sube un nuevo archivo
297
+ if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get('original_name'):
298
+ log(f"Nuevo archivo detectado, reseteando estado...")
299
+ st.session_state.video_uploaded = {'original_name': uploaded_file.name, 'status': 'validating'}
300
+ st.session_state.characters_detected = None
301
+ st.session_state.characters_saved = False
302
+
303
+ # --- Validación y Procesamiento ---
304
+ if st.session_state.video_uploaded['status'] == 'validating':
305
+ log(f"Validando archivo...")
306
+ is_valid = True
307
+ error_messages = []
308
+
309
+ # 1. Validar tamaño
310
+ if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
311
+ error_msg = f"El vídeo supera el límit de {MAX_SIZE_MB}MB. Tamaño actual: {uploaded_file.size / (1024*1024):.2f}MB"
312
+ log(f"ERROR: {error_msg}")
313
+ st.error(error_msg)
314
+ st.warning("💡 **Consejo**: Reduce el tamaño del vídeo o usa un clip más corto.")
315
+ error_messages.append(error_msg)
316
+ is_valid = False
317
+ else:
318
+ log(f"✓ Tamaño válido: {uploaded_file.size / (1024*1024):.2f} MB")
319
+
320
+ # 2. Validar que el archivo no esté vacío
321
+ if uploaded_file.size == 0:
322
+ error_msg = "El archivo está vacío."
323
+ log(f"ERROR: {error_msg}")
324
+ st.error(error_msg)
325
+ error_messages.append(error_msg)
326
+ is_valid = False
327
+
328
+ if is_valid:
329
+ try:
330
+ with st.spinner("Processant el vídeo..."):
331
+ log("Leyendo bytes del archivo...")
332
+ # Guardar el buffer en memoria para enviarlo al engine
333
+ video_bytes = uploaded_file.getbuffer().tobytes()
334
+ log(f"✓ Bytes leídos correctamente: {len(video_bytes)} bytes")
335
+
336
+ video_name = Path(uploaded_file.name).stem
337
+ log(f"Nombre del vídeo (sin extensión): {video_name}")
338
+
339
+ # Actualizar estado con los bytes del video
340
+ st.session_state.video_uploaded.update({
341
+ 'status': 'processed',
342
+ 'video_bytes': video_bytes,
343
+ 'video_name': f"{video_name}.mp4",
344
+ 'was_truncated': False
345
+ })
346
+ log(f"✓ Estado actualizado correctamente")
347
+ log(f"--- FIN SUBIDA DE VÍDEO (ÉXITO) ---\n")
348
+ st.rerun()
349
+ except Exception as e:
350
+ error_msg = f"Error al procesar el vídeo: {str(e)}"
351
+ log(f"ERROR CRÍTICO: {error_msg}")
352
+ log(f"Tipo de error: {type(e).__name__}")
353
+ import traceback
354
+ log(f"Traceback completo:\n{traceback.format_exc()}")
355
+ log(f"--- FIN SUBIDA DE VÍDEO (ERROR) ---\n")
356
+ st.error(error_msg)
357
+ st.session_state.video_uploaded = None
358
+ else:
359
+ log(f"Validación fallida. Errores: {error_messages}")
360
+ log(f"--- FIN SUBIDA DE VÍDEO (VALIDACIÓN FALLIDA) ---\n")
361
+ st.session_state.video_uploaded = None
362
+
363
+ # --- Mensajes de estado ---
364
+ if st.session_state.video_uploaded and st.session_state.video_uploaded['status'] == 'processed':
365
+ st.success(f"✅ Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
366
+
367
+ # Mostrar info del vídeo procesado
368
+ with st.expander("📊 Informació del vídeo", expanded=False):
369
+ st.write(f"**Nombre:** {st.session_state.video_uploaded['original_name']}")
370
+ st.write(f"**Tamaño:** {len(st.session_state.video_uploaded.get('video_bytes', [])) / (1024*1024):.2f} MB")
371
+ st.write(f"**Estado:** Processat i llest per detectar personatges")
372
+
373
+ if st.session_state.video_uploaded['was_truncated']:
374
+ st.warning(f"El vídeo s'ha truncat a {MAX_DURATION_S // 60} minuts.")
375
+
376
+ # --- 2. Detección de personajes ---
377
+ st.markdown("---")
378
+ col1, col2 = st.columns([1, 3])
379
+ # Sliders a la derecha del botón
380
+ with col2:
381
+ epsilon = st.slider("sensibitivity (épsilon)", 0.0, 2.0, 0.5, 0.1, key="epsilon_slider")
382
+ min_cluster_size = st.slider("mínimum cluster size", 1, 5, 2, 1, key="min_cluster_slider")
383
+ # Botón a la izquierda
384
+ with col1:
385
+ detect_button_disabled = st.session_state.video_uploaded is None
386
+ if st.button("Detectar Personatges", disabled=detect_button_disabled):
387
+ log(f"\n--- DETECCIÓN DE PERSONAJES INICIADA ---")
388
+ log(f"Estado del vídeo: {st.session_state.video_uploaded}")
389
+
390
+ with st.spinner("Detectant personatges..."):
391
+ # Llamar al endpoint del engine para crear el casting inicial
392
+ try:
393
+ video_bytes = st.session_state.video_uploaded.get('video_bytes') if st.session_state.video_uploaded else None
394
+ video_name = st.session_state.video_uploaded.get('video_name') if st.session_state.video_uploaded else None
395
+
396
+ # NO logear los bytes del vídeo (son binarios y ensucian el log)
397
+ log(f"Video bytes disponibles: {len(video_bytes) if video_bytes else 0} bytes")
398
+ log(f"Nombre del vídeo: {video_name}")
399
+
400
+ if not video_bytes:
401
+ error_msg = "No s'ha trobat el vídeo pujat en memòria."
402
+ log(f"ERROR: {error_msg}")
403
+ st.error(error_msg)
404
+ else:
405
+ # Verificar configuración antes de llamar
406
+ log(f"BACKEND_BASE_URL: {BACKEND_BASE_URL}")
407
+ log(f"API_TOKEN configurado: {'Sí' if API_TOKEN else 'No'}")
408
+
409
+ if BACKEND_BASE_URL == "http://localhost:8000" or not BACKEND_BASE_URL:
410
+ error_msg = "⚠️ **Error de configuració**: La URL del servei 'engine' no està configurada correctament."
411
+ log(f"ERROR: {error_msg}")
412
+ st.error(error_msg)
413
+ st.info(f"URL actual: `{BACKEND_BASE_URL}`\n\nConfigura la variable d'entorn `API_BASE_URL` amb la URL pública del Space 'engine'.")
414
+ else:
415
+ log(f"Llamando a create_initial_casting...")
416
+ log(f"Parámetros: epsilon={st.session_state.get('epsilon_slider', epsilon)}, min_cluster_size={int(st.session_state.get('min_cluster_slider', min_cluster_size))}")
417
+
418
+ resp = api.create_initial_casting(
419
+ video_bytes=video_bytes,
420
+ video_name=video_name,
421
+ epsilon=st.session_state.get("epsilon_slider", epsilon),
422
+ min_cluster_size=int(st.session_state.get("min_cluster_slider", min_cluster_size)),
423
+ )
424
+
425
+ log(f"Respuesta recibida: {resp}")
426
+
427
+ if isinstance(resp, dict) and resp.get("error"):
428
+ error_msg = resp['error']
429
+ log(f"ERROR en la respuesta: {error_msg}")
430
+ st.error(f"❌ **Error en 'create_initial_casting'**: {error_msg}")
431
+ if "403" in error_msg or "Forbidden" in error_msg:
432
+ st.warning("**Possible causes:**\n- El Space 'engine' no està accessible públicament\n- El token d'API no és correcte\n- CORS bloquejat")
433
+ elif "Connection" in error_msg or "timeout" in error_msg:
434
+ st.warning(f"**No s'ha pogut connectar** amb el servei engine a: `{BACKEND_BASE_URL}`")
435
+ else:
436
+ log(f"✓ Casting inicial creado con éxito")
437
+ st.success("✅ Casting inicial creat. S'han generat subcarpetes a 'temp/<uploaded-video>/*'.")
438
+ except Exception as e:
439
+ error_msg = f"❌ Error inesperat: {e}"
440
+ log(f"ERROR CRÍTICO: {error_msg}")
441
+ log(f"Tipo de error: {type(e).__name__}")
442
+ import traceback
443
+ log(f"Traceback completo:\n{traceback.format_exc()}")
444
+ st.error(error_msg)
445
+ finally:
446
+ log(f"--- FIN DETECCIÓN DE PERSONAJES ---\n")
447
+
448
+ # Datos de ejemplo para mostrar UI (mientras no tengamos retorno estructurado)
449
+ # NOTA: Comentado temporalmente porque placeholder.png no existe
450
+ # st.session_state.characters_detected = [
451
+ # {"id": "char1", "image_path": "init_data/placeholder.png", "description": "Dona amb cabell ros i ulleres"},
452
+ # {"id": "char2", "image_path": "init_data/placeholder.png", "description": "Home amb barba i barret"},
453
+ # ]
454
+ # st.session_state.characters_saved = False
455
+
456
+ # --- 3. Formularios de personajes ---
457
+ if st.session_state.characters_detected:
458
+ st.subheader("Personatges detectats")
459
+ for char in st.session_state.characters_detected:
460
+ with st.form(key=f"form_{char['id']}"):
461
+ col1, col2 = st.columns(2)
462
+ with col1:
463
+ st.image(char['image_path'], width=150)
464
+
465
+ with col2:
466
+ st.caption(char['description'])
467
+ st.text_input("Nom del personatge", key=f"name_{char['id']}")
468
+ st.form_submit_button("Cercar")
469
+
470
+ st.markdown("---_**")
471
+
472
+ # --- 4. Guardar y Generar ---
473
+ col1, col2, col3 = st.columns([1,1,2])
474
+ with col1:
475
+ if st.button("Desar", type="primary"):
476
+ # Aquí iría la lógica para guardar los nombres de los personajes
477
+ st.session_state.characters_saved = True
478
+ st.success("Personatges desats correctament.")
479
+
480
+ with col2:
481
+ if st.session_state.characters_saved:
482
+ st.button("Generar Audiodescripció")
483
+
484
+ elif page == "Analitzar audio-descripcions":
485
+ require_login()
486
+ st.header("Analitzar audio-descripcions")
487
+
488
+ # En HF Spaces, los videos están en el repo (read-only), no en /tmp/data
489
+ if os.getenv("SPACE_ID") is not None:
490
+ base_dir = Path(__file__).resolve().parent / "videos"
491
+ else:
492
+ base_dir = PROJECT_ROOT / "videos"
493
+
494
+ if not base_dir.exists():
495
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
496
+ st.stop()
497
+
498
+ carpetes = [p.name for p in sorted(base_dir.iterdir()) if p.is_dir() and p.name != 'completed']
499
+ if not carpetes:
500
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
501
+ st.stop()
502
+
503
+ # --- Lógica de Estado y Selección ---
504
+
505
+ # Detectar si el vídeo principal ha cambiado para resetear el estado secundario
506
+ if 'current_video' not in st.session_state:
507
+ st.session_state.current_video = None
508
+
509
+ # Widget de selección de vídeo
510
+ seleccio = st.selectbox("Selecciona un vídeo (carpeta):", carpetes, index=None, placeholder="Tria una carpeta…")
511
+
512
+ if seleccio != st.session_state.current_video:
513
+ st.session_state.current_video = seleccio
514
+ # Forzar reseteo de los widgets dependientes
515
+ # No establecemos a None, dejamos que el widget se inicialice
516
+ st.session_state.add_ad_checkbox = False
517
+ if 'version_selector' in st.session_state:
518
+ del st.session_state['version_selector'] # Forzar reinicio completo del widget
519
+ st.rerun()
520
+
521
+ if not seleccio:
522
+ st.stop()
523
+
524
+ vid_dir = base_dir / seleccio
525
+ mp4s = sorted(vid_dir.glob("*.mp4"))
526
+
527
+ # --- Dibujado de la Interfaz ---
528
+ col_video, col_txt = st.columns([2, 1], gap="large")
529
+
530
+ with col_video:
531
+ # Selección de versión
532
+ subcarpetas_ad = [p.name for p in sorted(vid_dir.iterdir()) if p.is_dir()]
533
+ default_index_sub = subcarpetas_ad.index("Salamandra") if "Salamandra" in subcarpetas_ad else 0
534
+ subcarpeta_seleccio = st.selectbox(
535
+ "Selecciona una versió d'audiodescripció:", subcarpetas_ad,
536
+ index=default_index_sub if subcarpetas_ad and 'version_selector' not in st.session_state else 0,
537
+ placeholder="Tria una versió…" if subcarpetas_ad else "No hi ha versions",
538
+ key="version_selector"
539
+ )
540
+
541
+ # Lógica de vídeo AD
542
+ video_ad_path = vid_dir / subcarpeta_seleccio / "une_ad.mp4" if subcarpeta_seleccio else None
543
+ is_ad_video_available = video_ad_path is not None and video_ad_path.exists()
544
+
545
+ # Checkbox
546
+ add_ad_video = st.checkbox("Afegir audiodescripció", disabled=not is_ad_video_available, key="add_ad_checkbox")
547
+
548
+ # Decidir qué vídeo mostrar
549
+ video_to_show = None
550
+ if add_ad_video and is_ad_video_available:
551
+ video_to_show = video_ad_path
552
+ elif mp4s:
553
+ video_to_show = mp4s[0]
554
+
555
+ if video_to_show:
556
+ st.video(str(video_to_show))
557
+ else:
558
+ st.warning("No s'ha trobat cap fitxer **.mp4** a la carpeta seleccionada.")
559
+
560
+ st.markdown("---")
561
+
562
+ # Sección de ACCIONES
563
+ st.markdown("#### Accions")
564
+ c1, c2 = st.columns(2)
565
+ with c1:
566
+ if st.button("Reconstruir àudio amb narració lliure", use_container_width=True, key="rebuild_free_ad"):
567
+ if seleccio and subcarpeta_seleccio:
568
+ with st.spinner("Generant àudio de la narració lliure..."):
569
+ result = generate_free_ad_mp3(seleccio, subcarpeta_seleccio, api, PROJECT_ROOT)
570
+ if result.get("status") == "success":
571
+ st.success(f"Àudio generat amb èxit: {result.get('path')}")
572
+ else:
573
+ st.error(f"Error: {result.get('reason', 'Desconegut')}")
574
+ else:
575
+ st.warning("Selecciona un vídeo i una versió.")
576
+
577
+ with c2:
578
+ if st.button("Reconstruir vídeo amb audiodescripció", use_container_width=True, key="rebuild_video_ad"):
579
+ if seleccio and subcarpeta_seleccio:
580
+ with st.spinner("Reconstruint el vídeo... Aquesta operació pot trigar."):
581
+ result = generate_une_ad_video(seleccio, subcarpeta_seleccio, api, PROJECT_ROOT)
582
+ if result.get("status") == "success":
583
+ st.success(f"Vídeo generat amb èxit: {result.get('path')}")
584
+ st.info("Pots visualitzar-lo activant la casella 'Afegir audiodescripció'.")
585
+ else:
586
+ st.error(f"Error: {result.get('reason', 'Desconegut')}")
587
+ else:
588
+ st.warning("Selecciona un vídeo i una versió.")
589
+
590
+
591
+ # --- Columna Derecha (Editor de texto y guardado) ---
592
+ with col_txt:
593
+ tipus_ad_options = ["narració lliure", "UNE-153010"]
594
+ tipus_ad_seleccio = st.selectbox("Fitxer d'audiodescripció a editar:", tipus_ad_options)
595
+
596
+ ad_filename = "free_ad.txt" if tipus_ad_seleccio == "narració lliure" else "une_ad.srt"
597
+
598
+ # Cargar el contenido del fichero seleccionado
599
+ text_content = ""
600
+ ad_path = None
601
+ if subcarpeta_seleccio:
602
+ ad_path = vid_dir / subcarpeta_seleccio / ad_filename
603
+ if ad_path.exists():
604
+ try:
605
+ text_content = ad_path.read_text(encoding="utf-8")
606
+ except Exception:
607
+ text_content = ad_path.read_text(errors="ignore")
608
+ else:
609
+ st.info(f"No s'ha trobat el fitxer **{ad_filename}**.")
610
+ else:
611
+ # Eliminada la nota de advertencia
612
+ pass
613
+
614
+ # Área de texto para edición
615
+ new_text = st.text_area(f"Contingut de {tipus_ad_seleccio}", value=text_content, height=500, key=f"editor_{seleccio}_{subcarpeta_seleccio}_{ad_filename}")
616
+
617
+ # Botón de guardado
618
+ if st.button("Desar canvis", use_container_width=True, type="primary"):
619
+ if ad_path:
620
+ try:
621
+ save_text(ad_path, new_text)
622
+ st.success(f"Fitxer **{ad_filename}** desat correctament.")
623
+ # El st.rerun() ya se encarga de recargar el estado desde el fichero guardado.
624
+ # La línea que modificaba el session_state directamente ha sido eliminada para evitar el error.
625
+ st.rerun()
626
+ except Exception as e:
627
+ st.error(f"No s'ha pogut desar el fitxer: {e}")
628
+ else:
629
+ st.error("No s'ha seleccionat una ruta de fitxer vàlida per desar.")
630
+
631
+ # Controles de reproducción de narración
632
+ free_ad_mp3_path = vid_dir / subcarpeta_seleccio / "free_ad.mp3" if seleccio and subcarpeta_seleccio else None
633
+ can_play_free_ad = free_ad_mp3_path is not None and free_ad_mp3_path.exists()
634
+
635
+ if st.button("▶️ Reproduir narració lliure", use_container_width=True, disabled=not can_play_free_ad, key="play_button_editor"):
636
+ if can_play_free_ad:
637
+ st.audio(str(free_ad_mp3_path), format="audio/mp3")
638
+ else:
639
+ st.warning("No s'ha trobat el fitxer 'free_ad.mp3'. Reconstrueix l'àudio primer.")
640
+
641
+
642
+ st.markdown("---")
643
+ st.subheader("Avaluació de la qualitat de l'audiodescripció")
644
+
645
+ c1, c2, c3 = st.columns(3)
646
+ with c1:
647
+ transcripcio = st.slider("Transcripció", 1, 10, 7)
648
+ identificacio = st.slider("Identificació de personatges", 1, 10, 7)
649
+ with c2:
650
+ localitzacions = st.slider("Localitzacions", 1, 10, 7)
651
+ activitats = st.slider("Activitats", 1, 10, 7)
652
+ with c3:
653
+ narracions = st.slider("Narracions", 1, 10, 7)
654
+ expressivitat = st.slider("Expressivitat", 1, 10, 7)
655
+
656
+ comments = st.text_area("Comentaris (opcional)", placeholder="Escriu els teus comentaris lliures…", height=120)
657
+
658
+ role = st.session_state.user["role"]
659
+ can_rate = role in ("verd", "groc", "blau")
660
+
661
+ if not can_rate:
662
+ st.info("El teu rol no permet enviar valoracions.")
663
+ else:
664
+ if st.button("Enviar valoració", type="primary", use_container_width=True):
665
+ try:
666
+ add_feedback_ad(
667
+ video_name=seleccio,
668
+ user_id=st.session_state.user["id"],
669
+ transcripcio=transcripcio,
670
+ identificacio=identificacio,
671
+ localitzacions=localitzacions,
672
+ activitats=activitats,
673
+ narracions=narracions,
674
+ expressivitat=expressivitat,
675
+ comments=comments or None
676
+ )
677
+ st.success("Gràcies! La teva valoració s'ha desat correctament.")
678
+ except Exception as e:
679
+ st.error(f"S'ha produït un error en desar la valoració: {e}")
680
+
681
+
682
+ elif page == "Estadístiques":
683
+ require_login()
684
+ st.header("Estadístiques")
685
+
686
+ from database import get_feedback_ad_stats
687
+ stats = get_feedback_ad_stats() # medias por vídeo + avg_global
688
+ if not stats:
689
+ st.caption("Encara no hi ha valoracions.")
690
+ st.stop()
691
+
692
+ import pandas as pd
693
+ df = pd.DataFrame(stats, columns=stats[0].keys())
694
+ ordre = st.radio("Ordre de rànquing", ["Descendent (millors primer)", "Ascendent (pitjors primer)"], horizontal=True)
695
+ if ordre.startswith("Asc"):
696
+ df = df.sort_values("avg_global", ascending=True)
697
+ else:
698
+ df = df.sort_values("avg_global", ascending=False)
699
+
700
+ st.subheader("Rànquing de vídeos")
701
+ st.dataframe(
702
+ df[["video_name","n","avg_global","avg_transcripcio","avg_identificacio","avg_localitzacions","avg_activitats","avg_narracions", "avg_expressivitat"]],
703
+ use_container_width=True
704
+ )
705
+
test_connection.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script de diagnóstico para verificar la conexión entre demo y engine
4
+ """
5
+ import requests
6
+ import os
7
+
8
+ # Configura aquí la URL de tu Space engine
9
+ ENGINE_URL = os.getenv("API_BASE_URL", "https://veureu-engine.hf.space")
10
+
11
+ print("=" * 60)
12
+ print("DIAGNÓSTICO DE CONEXIÓN: demo → engine")
13
+ print("=" * 60)
14
+ print(f"\n1. URL configurada: {ENGINE_URL}")
15
+
16
+ # Test 1: Verificar que el endpoint raíz responde
17
+ print("\n2. Probando endpoint raíz (GET /)...")
18
+ try:
19
+ response = requests.get(f"{ENGINE_URL}/", timeout=10)
20
+ print(f" ✅ Status: {response.status_code}")
21
+ print(f" ✅ Respuesta: {response.json()}")
22
+ except requests.exceptions.ConnectionError as e:
23
+ print(f" ❌ Error de conexión: {e}")
24
+ print(" → El Space puede estar en modo 'sleeping' o la URL es incorrecta")
25
+ except requests.exceptions.Timeout:
26
+ print(f" ❌ Timeout: El servidor no respondió en 10 segundos")
27
+ except Exception as e:
28
+ print(f" ❌ Error: {e}")
29
+
30
+ # Test 2: Verificar el endpoint /create_initial_casting
31
+ print("\n3. Probando endpoint /create_initial_casting (POST)...")
32
+ try:
33
+ # Crear un video de prueba muy pequeño (1 byte)
34
+ files = {
35
+ "video": ("test.mp4", b"x", "video/mp4"),
36
+ }
37
+ data = {
38
+ "epsilon": "0.5",
39
+ "min_cluster_size": "2",
40
+ }
41
+ response = requests.post(
42
+ f"{ENGINE_URL}/create_initial_casting",
43
+ files=files,
44
+ data=data,
45
+ timeout=30
46
+ )
47
+ print(f" ✅ Status: {response.status_code}")
48
+ if response.status_code == 200:
49
+ print(f" ✅ Respuesta: {response.json()}")
50
+ else:
51
+ print(f" ⚠️ Respuesta: {response.text[:200]}")
52
+ except requests.exceptions.HTTPError as e:
53
+ print(f" ❌ Error HTTP {e.response.status_code}: {e.response.text[:200]}")
54
+ if e.response.status_code == 403:
55
+ print(" → Error 403: Verifica que el Space 'engine' sea público")
56
+ print(" → O configura API_SHARED_TOKEN con un token válido")
57
+ elif e.response.status_code == 422:
58
+ print(" → Error 422: Los parámetros no son válidos (esto es normal con video fake)")
59
+ except Exception as e:
60
+ print(f" ❌ Error: {e}")
61
+
62
+ print("\n" + "=" * 60)
63
+ print("RECOMENDACIONES:")
64
+ print("=" * 60)
65
+ print("1. Verifica que la URL sea exactamente tu Space engine en HF")
66
+ print("2. Asegúrate de que el Space 'engine' esté en estado 'Running'")
67
+ print("3. Si el Space está 'sleeping', abrelo en el navegador para activarlo")
68
+ print("4. Verifica que ambos Spaces sean públicos (o configura token)")
69
+ print("=" * 60)