VeuReu commited on
Commit
679871f
·
1 Parent(s): f471d1e

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. app.py +464 -462
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  title: veureu-app
3
  emoji: 🚀
4
- colorFrom: red
5
  colorTo: red
6
  sdk: docker
7
  app_port: 7860
 
1
  ---
2
  title: veureu-app
3
  emoji: 🚀
4
+ colorFrom: yellow
5
  colorTo: red
6
  sdk: docker
7
  app_port: 7860
app.py CHANGED
@@ -1,462 +1,464 @@
1
- import os
2
- import io
3
- import json
4
- import yaml
5
- import shutil
6
- from pathlib import Path
7
- from passlib.hash import bcrypt
8
- try:
9
- import tomllib
10
- except ModuleNotFoundError: # Py<3.11
11
- import tomli as tomllib
12
- import streamlit as st
13
- from moviepy.editor import VideoFileClip
14
-
15
- from database import set_db_path, init_schema, get_user, 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
16
- from api_client import APIClient
17
- from utils import ensure_dirs, save_bytes, save_text, human_size
18
-
19
-
20
- # -- Move DB ---
21
- os.environ["STREAMLIT_DATA_DIRECTORY"] = "/tmp/.streamlit"
22
- Path("/tmp/.streamlit").mkdir(parents=True, exist_ok=True)
23
- Path("/tmp/data").mkdir(parents=True, exist_ok=True)
24
- source_db = "init_data/veureu.db"
25
- target_db = "/tmp/data/app.db"
26
- if not os.path.exists(target_db) and os.path.exists(source_db):
27
- shutil.copy(source_db, target_db)
28
-
29
- static_videos = Path(__file__).parent / "videos"
30
- runtime_videos = Path("/tmp/data/videos")
31
- if not runtime_videos.exists():
32
- shutil.copytree(static_videos, runtime_videos, dirs_exist_ok=True)
33
-
34
-
35
- # --- Config ---
36
- def _load_yaml(path="config.yaml") -> dict:
37
- with open(path, "r", encoding="utf-8") as f:
38
- cfg = yaml.safe_load(f) or {}
39
- # interpolación sencilla de ${VARS} si las usas en el YAML
40
- def _subst(s: str) -> str:
41
- return os.path.expandvars(s) if isinstance(s, str) else s
42
-
43
- # aplica sustitución en los campos que te interesan
44
- if "api" in cfg:
45
- cfg["api"]["base_url"] = _subst(cfg["api"].get("base_url", ""))
46
- cfg["api"]["token"] = _subst(cfg["api"].get("token", ""))
47
-
48
- if "storage" in cfg and "root_dir" in cfg["storage"]:
49
- cfg["storage"]["root_dir"] = _subst(cfg["storage"]["root_dir"])
50
-
51
- if "sqlite" in cfg and "path" in cfg["sqlite"]:
52
- cfg["sqlite"]["path"] = _subst(cfg["sqlite"]["path"])
53
-
54
- return cfg
55
-
56
- CFG = _load_yaml("config.yaml")
57
-
58
- # Ajuste de variables según tu esquema YAML
59
- DATA_DIR = CFG.get("storage", {}).get("root_dir", "data")
60
- BACKEND_BASE_URL = CFG.get("api", {}).get("base_url", "http://localhost:8000")
61
- USE_MOCK = bool(CFG.get("app", {}).get("use_mock", False)) # si no la tienes en el yaml, queda False
62
- API_TOKEN = CFG.get("api", {}).get("token") or os.getenv("API_SHARED_TOKEN")
63
-
64
- os.makedirs(DATA_DIR, exist_ok=True)
65
- ensure_dirs(DATA_DIR)
66
- DB_PATH = os.path.join(DATA_DIR, "app.db")
67
- set_db_path(DB_PATH)
68
- init_schema()
69
-
70
- api = APIClient(BACKEND_BASE_URL, use_mock=USE_MOCK, data_dir=DATA_DIR, token=API_TOKEN)
71
-
72
- st.set_page_config(page_title="Veureu — Audiodescripció", page_icon="🎬", layout="wide")
73
-
74
- # --- Session: auth ---
75
- # print("Usuarios disponibles:", get_all_users()) # Descomentar para depurar
76
- if "user" not in st.session_state:
77
- st.session_state.user = None # dict with {username, role, id(optional)}
78
-
79
- def require_login():
80
- if not st.session_state.user:
81
- st.info("Por favor, inicia sesión para continuar.")
82
- login_form()
83
- st.stop()
84
-
85
- def verify_password(password: str, pw_hash: str) -> bool:
86
- try:
87
- return bcrypt.verify(password, pw_hash)
88
- except Exception:
89
- return False
90
-
91
- # --- Sidebar (only after login) ---
92
- role = st.session_state.user["role"] if st.session_state.user else None
93
- with st.sidebar:
94
- st.title("Veureu")
95
- if st.session_state.user:
96
- st.write(f"Usuari: **{st.session_state.user['username']}** (rol: {st.session_state.user['role']})")
97
- if st.button("Tancar sessió"):
98
- st.session_state.user = None
99
- st.rerun()
100
- if st.session_state.user:
101
- page = st.radio("Navegació", ["Analitzar video-transcripcions","Processar vídeo nou","Estadístiques"], index=0)
102
- else:
103
- page = None
104
-
105
- # --- Pre-login screen ---
106
- if not st.session_state.user:
107
- st.title("Veureu — Audiodescripció")
108
- def login_form():
109
- st.subheader("Inici de sessió")
110
- username = st.text_input("Usuari")
111
- password = st.text_input("Contrasenya", type="password")
112
- if st.button("Entrar", type="primary"):
113
- row = get_user(username)
114
- if row and verify_password(password, row["pw_hash"]):
115
- st.session_state.user = {"id": row["id"], "username": row["username"], "role": row["role"]}
116
- st.success(f"Benvingut/da, {row['username']}")
117
- st.rerun()
118
- else:
119
- st.error("Credencials invàlides")
120
- login_form()
121
- st.stop()
122
-
123
- # --- Pages ---
124
- if page == "Processar vídeo nou":
125
- require_login()
126
- if role != "verd":
127
- st.error("No tens permisos per processar nous vídeos. Canvia d'usuari o sol·licita permisos.")
128
- st.stop()
129
-
130
- st.header("Processar un nou clip de vídeo")
131
-
132
- # Inicializar el estado de la página si no existe
133
- if 'video_uploaded' not in st.session_state:
134
- st.session_state.video_uploaded = None
135
- if 'characters_detected' not in st.session_state:
136
- st.session_state.characters_detected = None
137
- if 'characters_saved' not in st.session_state:
138
- st.session_state.characters_saved = False
139
-
140
- # --- 1. Subida del vídeo ---
141
- MAX_SIZE_MB = 20
142
- MAX_DURATION_S = 240 # 4 minutos
143
-
144
- uploaded_file = st.file_uploader("Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)", type=["mp4"], key="video_uploader")
145
-
146
- if uploaded_file is not None:
147
- # Resetear el estado si se sube un nuevo archivo
148
- if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get('original_name'):
149
- st.session_state.video_uploaded = {'original_name': uploaded_file.name, 'status': 'validating'}
150
- st.session_state.characters_detected = None
151
- st.session_state.characters_saved = False
152
-
153
- # --- Validación y Procesamiento ---
154
- if st.session_state.video_uploaded['status'] == 'validating':
155
- is_valid = True
156
- # 1. Validar tamaño
157
- if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
158
- st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
159
- is_valid = False
160
-
161
- if is_valid:
162
- with st.spinner("Processant el vídeo..."):
163
- # Guardar temporalmente para analizarlo
164
- with open("temp_video.mp4", "wb") as f:
165
- f.write(uploaded_file.getbuffer())
166
-
167
- clip = VideoFileClip("temp_video.mp4")
168
- duration = clip.duration
169
-
170
- # 2. Validar y truncar duración
171
- was_truncated = False
172
- if duration > MAX_DURATION_S:
173
- clip = clip.subclip(0, MAX_DURATION_S)
174
- was_truncated = True
175
-
176
- # Crear carpeta y guardar el vídeo final
177
- video_name = Path(uploaded_file.name).stem
178
- video_dir = Path("/tmp/data/videos") / video_name
179
- video_dir.mkdir(parents=True, exist_ok=True)
180
- final_video_path = video_dir / f"{video_name}.mp4"
181
- clip.write_videofile(str(final_video_path), codec="libx264", audio_codec="aac")
182
-
183
- clip.close()
184
- os.remove("temp_video.mp4")
185
-
186
- # Actualizar estado
187
- st.session_state.video_uploaded.update({
188
- 'status': 'processed',
189
- 'path': str(final_video_path),
190
- 'was_truncated': was_truncated
191
- })
192
- st.rerun()
193
-
194
- # --- Mensajes de estado ---
195
- if st.session_state.video_uploaded and st.session_state.video_uploaded['status'] == 'processed':
196
- st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
197
- if st.session_state.video_uploaded['was_truncated']:
198
- st.warning(f"El vídeo s'ha truncat a {MAX_DURATION_S // 60} minuts.")
199
-
200
- # --- 2. Detección de personajes ---
201
- st.markdown("---")
202
- col1, col2 = st.columns([1, 3])
203
- with col1:
204
- detect_button_disabled = st.session_state.video_uploaded is None
205
- if st.button("Detectar Personatges", disabled=detect_button_disabled):
206
- with st.spinner("Detectant personatges..."):
207
- # Aquí iría la llamada a la API para detectar personajes
208
- # Por ahora, usamos datos de ejemplo
209
- st.session_state.characters_detected = [
210
- {"id": "char1", "image_path": "init_data/placeholder.png", "description": "Dona amb cabell ros i ulleres"},
211
- {"id": "char2", "image_path": "init_data/placeholder.png", "description": "Home amb barba i barret"},
212
- ]
213
- st.session_state.characters_saved = False # Resetear el estado de guardado
214
-
215
- # --- 3. Formularios de personajes ---
216
- if st.session_state.characters_detected:
217
- st.subheader("Personatges detectats")
218
- for char in st.session_state.characters_detected:
219
- with st.form(key=f"form_{char['id']}"):
220
- col1, col2 = st.columns(2)
221
- with col1:
222
- st.image(char['image_path'], width=150)
223
-
224
- with col2:
225
- st.caption(char['description'])
226
- st.text_input("Nom del personatge", key=f"name_{char['id']}")
227
- st.form_submit_button("Cercar")
228
-
229
- st.markdown("---_**")
230
-
231
- # --- 4. Guardar y Generar ---
232
- col1, col2, col3 = st.columns([1,1,2])
233
- with col1:
234
- if st.button("Desar", type="primary"):
235
- # Aquí iría la lógica para guardar los nombres de los personajes
236
- st.session_state.characters_saved = True
237
- st.success("Personatges desats correctament.")
238
-
239
- with col2:
240
- if st.session_state.characters_saved:
241
- st.button("Generar Audiodescripci��")
242
-
243
- elif page == "Analitzar video-transcripcions":
244
- require_login()
245
- st.header("Analitzar video-transcripcions")
246
- base_dir = Path("/tmp/data/videos")
247
-
248
- if not base_dir.exists():
249
- st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
250
- st.stop()
251
-
252
- carpetes = [p.name for p in sorted(base_dir.iterdir()) if p.is_dir() and p.name != 'completed']
253
- if not carpetes:
254
- st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
255
- st.stop()
256
-
257
- # --- Lógica de Estado y Selección ---
258
-
259
- # Detectar si el vídeo principal ha cambiado para resetear el estado secundario
260
- if 'current_video' not in st.session_state:
261
- st.session_state.current_video = None
262
-
263
- # Widget de selección de vídeo
264
- seleccio = st.selectbox("Selecciona un vídeo (carpeta):", carpetes, index=None, placeholder="Tria una carpeta…")
265
-
266
- if seleccio != st.session_state.current_video:
267
- st.session_state.current_video = seleccio
268
- # Forzar reseteo de los widgets dependientes
269
- st.session_state.version_selector = None
270
- st.session_state.add_ad_checkbox = False
271
- st.rerun()
272
-
273
- if not seleccio:
274
- st.stop()
275
-
276
- vid_dir = base_dir / seleccio
277
- mp4s = sorted(vid_dir.glob("*.mp4"))
278
-
279
- # --- Dibujado de la Interfaz ---
280
- col_video, col_txt = st.columns([2, 1], gap="large")
281
-
282
- with col_video:
283
- # Selección de versión
284
- subcarpetas_ad = [p.name for p in sorted(vid_dir.iterdir()) if p.is_dir()]
285
- default_index_sub = subcarpetas_ad.index("Salamandra") if "Salamandra" in subcarpetas_ad else 0
286
- subcarpeta_seleccio = st.selectbox(
287
- "Selecciona una versió d'audiodescripció:", subcarpetas_ad,
288
- index=default_index_sub if subcarpetas_ad else None,
289
- placeholder="Tria una versió…" if subcarpetas_ad else "No hi ha versions",
290
- key="version_selector"
291
- )
292
-
293
- # Lógica de vídeo AD
294
- video_ad_path = vid_dir / subcarpeta_seleccio / "une_ad.mp4" if subcarpeta_seleccio else None
295
- is_ad_video_available = video_ad_path is not None and video_ad_path.exists()
296
-
297
- # Checkbox
298
- add_ad_video = st.checkbox("Afegir audiodescripció", disabled=not is_ad_video_available, key="add_ad_checkbox")
299
-
300
- # Decidir qué vídeo mostrar
301
- video_to_show = None
302
- if add_ad_video and is_ad_video_available:
303
- video_to_show = video_ad_path
304
- elif mp4s:
305
- video_to_show = mp4s[0]
306
-
307
- if video_to_show:
308
- st.video(str(video_to_show))
309
- else:
310
- st.warning("No s'ha trobat cap fitxer **.mp4** a la carpeta seleccionada.")
311
-
312
- st.markdown("---")
313
-
314
- # Sección de ACCIONES
315
- st.markdown("#### Accions")
316
- c1, c2 = st.columns(2)
317
- with c1:
318
- if st.button("Reconstruir àudio amb narració lliure", use_container_width=True, key="rebuild_free_ad"):
319
- if subcarpeta_seleccio:
320
- free_ad_path = vid_dir / subcarpeta_seleccio / "free_ad.txt"
321
- if free_ad_path.exists():
322
- with st.spinner("Generant àudio de la narració lliure..."):
323
- text_content = free_ad_path.read_text(encoding="utf-8")
324
- voice = "central/grau" # Voz fijada
325
- response = api.tts_matxa(text=text_content, voice=voice)
326
- if "mp3_bytes" in response:
327
- output_path = vid_dir / subcarpeta_seleccio / "free_ad.mp3"
328
- save_bytes(output_path, response["mp3_bytes"])
329
- st.success(f"Àudio generat i desat a: {output_path}")
330
- else:
331
- st.error(f"Error en la generació de l'àudio: {response.get('error', 'Desconegut')}")
332
- else:
333
- st.warning("No s'ha trobat el fitxer 'free_ad.txt' en aquesta versió.")
334
-
335
- with c2:
336
- if st.button("Reconstruir vídeo amb audiodescripció", use_container_width=True, key="rebuild_video_ad"):
337
- if subcarpeta_seleccio and mp4s:
338
- une_srt_path = vid_dir / subcarpeta_seleccio / "une_ad.srt"
339
- video_original_path = mp4s[0]
340
- if une_srt_path.exists():
341
- with st.spinner("Reconstruint el vídeo amb l'audiodescripció... Aquesta operació pot trigar una estona."):
342
- response = api.rebuild_video_with_ad(video_path=str(video_original_path), srt_path=str(une_srt_path))
343
- if "video_bytes" in response:
344
- output_path = vid_dir / subcarpeta_seleccio / "video_ad_rebuilt.mp4"
345
- save_bytes(output_path, response["video_bytes"])
346
- st.success(f"Vídeo reconstruït i desat a: {output_path}")
347
- st.info("Pots visualitzar-lo activant la casella 'Afegir audiodescripció' i seleccionant el nou fitxer si cal.")
348
- else:
349
- st.error(f"Error en la reconstrucció del vídeo: {response.get('error', 'Desconegut')}")
350
- else:
351
- st.warning("No s'ha trobat el fitxer 'une_ad.srt' en aquesta versió.")
352
-
353
-
354
- # --- Columna Derecha (Editor de texto y guardado) ---
355
- with col_txt:
356
- tipus_ad_options = ["narració lliure", "UNE-153010"]
357
- tipus_ad_seleccio = st.selectbox("Fitxer d'audiodescripció a editar:", tipus_ad_options)
358
-
359
- ad_filename = "free_ad.txt" if tipus_ad_seleccio == "narració lliure" else "une_ad.srt"
360
-
361
- # Cargar el contenido del fichero seleccionado
362
- text_content = ""
363
- ad_path = None
364
- if subcarpeta_seleccio:
365
- ad_path = vid_dir / subcarpeta_seleccio / ad_filename
366
- if ad_path.exists():
367
- try:
368
- text_content = ad_path.read_text(encoding="utf-8")
369
- except Exception:
370
- text_content = ad_path.read_text(errors="ignore")
371
- else:
372
- st.info(f"No s'ha trobat el fitxer **{ad_filename}**.")
373
- else:
374
- st.warning("Selecciona una versió per veure els fitxers.")
375
-
376
- # Área de texto para edición
377
- new_text = st.text_area(f"Contingut de {tipus_ad_seleccio}", value=text_content, height=500, key=f"editor_{seleccio}_{subcarpeta_seleccio}_{ad_filename}")
378
-
379
- # Controles de reproducción de narración (selector de voz eliminado)
380
- if st.button("▶️ Reproduir narració", use_container_width=True, disabled=not new_text.strip(), key="play_button_editor"):
381
- with st.spinner("Generant àudio..."):
382
- # Lógica de TTS con el texto del área
383
- pass # Implementación de la llamada a la API TTS
384
-
385
- # Botón de guardado
386
- if st.button("Desar canvis", use_container_width=True, type="primary"):
387
- if ad_path:
388
- try:
389
- ad_path.write_text(new_text, encoding="utf-8")
390
- st.success(f"Fitxer **{ad_filename}** desat correctament.")
391
- st.rerun()
392
- except Exception as e:
393
- st.error(f"No s'ha pogut desar el fitxer: {e}")
394
- else:
395
- st.error("No s'ha seleccionat una ruta de fitxer vàlida per desar.")
396
-
397
-
398
- st.markdown("---")
399
- st.subheader("Avaluació de la qualitat de l'audiodescripció")
400
-
401
- c1, c2, c3 = st.columns(3)
402
- with c1:
403
- transcripcio = st.slider("Transcripció", 1, 10, 7)
404
- identificacio = st.slider("Identificació de personatges", 1, 10, 7)
405
- with c2:
406
- localitzacions = st.slider("Localitzacions", 1, 10, 7)
407
- activitats = st.slider("Activitats", 1, 10, 7)
408
- with c3:
409
- narracions = st.slider("Narracions", 1, 10, 7)
410
- expressivitat = st.slider("Expressivitat", 1, 10, 7)
411
-
412
- comments = st.text_area("Comentaris (opcional)", placeholder="Escriu els teus comentaris lliures…", height=120)
413
-
414
- role = st.session_state.user["role"]
415
- can_rate = role in ("verd", "groc", "blau")
416
-
417
- if not can_rate:
418
- st.info("El teu rol no permet enviar valoracions.")
419
- else:
420
- if st.button("Enviar valoració", type="primary", use_container_width=True):
421
- try:
422
- from database import add_feedback_ad
423
- add_feedback_ad(
424
- video_name=seleccio,
425
- user_id=st.session_state.user["id"],
426
- transcripcio=transcripcio,
427
- identificacio=identificacio,
428
- localitzacions=localitzacions,
429
- activitats=activitats,
430
- narracions=narracions,
431
- expressivitat=expressivitat,
432
- comments=comments or None
433
- )
434
- st.success("Gràcies! La teva valoració s'ha desat correctament.")
435
- except Exception as e:
436
- st.error(f"S'ha produït un error en desar la valoració: {e}")
437
-
438
-
439
- elif page == "Estadístiques":
440
- require_login()
441
- st.header("Estadístiques")
442
-
443
- from database import get_feedback_ad_stats
444
- stats = get_feedback_ad_stats() # medias por vídeo + avg_global
445
- if not stats:
446
- st.caption("Encara no hi ha valoracions.")
447
- st.stop()
448
-
449
- import pandas as pd
450
- df = pd.DataFrame(stats, columns=stats[0].keys())
451
- ordre = st.radio("Ordre de rànquing", ["Descendent (millors primer)", "Ascendent (pitjors primer)"], horizontal=True)
452
- if ordre.startswith("Asc"):
453
- df = df.sort_values("avg_global", ascending=True)
454
- else:
455
- df = df.sort_values("avg_global", ascending=False)
456
-
457
- st.subheader("Rànquing de vídeos")
458
- st.dataframe(
459
- df[["video_name","n","avg_global","avg_transcripcio","avg_identificacio","avg_localitzacions","avg_activitats","avg_narracions", "avg_expressivitat"]],
460
- use_container_width=True
461
- )
462
-
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ import yaml
5
+ import shutil
6
+ from pathlib import Path
7
+ from passlib.hash import bcrypt
8
+ try:
9
+ import tomllib
10
+ except ModuleNotFoundError: # Py<3.11
11
+ import tomli as tomllib
12
+ import streamlit as st
13
+ from moviepy.editor import VideoFileClip
14
+
15
+ from database import set_db_path, init_schema, get_user, 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
16
+ from api_client import APIClient
17
+ from utils import ensure_dirs, save_bytes, save_text, human_size
18
+
19
+
20
+ # -- Move DB ---
21
+ os.environ["STREAMLIT_DATA_DIRECTORY"] = "/tmp/.streamlit"
22
+ Path("/tmp/.streamlit").mkdir(parents=True, exist_ok=True)
23
+ Path("/tmp/data").mkdir(parents=True, exist_ok=True)
24
+ source_db = "init_data/veureu.db"
25
+ target_db = "/tmp/data/app.db"
26
+ if not os.path.exists(target_db) and os.path.exists(source_db):
27
+ shutil.copy(source_db, target_db)
28
+
29
+ static_videos = Path(__file__).parent / "videos"
30
+ runtime_videos = Path("/tmp/data/videos")
31
+ if not runtime_videos.exists():
32
+ shutil.copytree(static_videos, runtime_videos, dirs_exist_ok=True)
33
+
34
+
35
+ # --- Config ---
36
+ def _load_yaml(path="config.yaml") -> dict:
37
+ with open(path, "r", encoding="utf-8") as f:
38
+ cfg = yaml.safe_load(f) or {}
39
+ # interpolación sencilla de ${VARS} si las usas en el YAML
40
+ def _subst(s: str) -> str:
41
+ return os.path.expandvars(s) if isinstance(s, str) else s
42
+
43
+ # aplica sustitución en los campos que te interesan
44
+ if "api" in cfg:
45
+ cfg["api"]["base_url"] = _subst(cfg["api"].get("base_url", ""))
46
+ cfg["api"]["token"] = _subst(cfg["api"].get("token", ""))
47
+
48
+ if "storage" in cfg and "root_dir" in cfg["storage"]:
49
+ cfg["storage"]["root_dir"] = _subst(cfg["storage"]["root_dir"])
50
+
51
+ if "sqlite" in cfg and "path" in cfg["sqlite"]:
52
+ cfg["sqlite"]["path"] = _subst(cfg["sqlite"]["path"])
53
+
54
+ return cfg
55
+
56
+ CFG = _load_yaml("config.yaml")
57
+
58
+ # Ajuste de variables según tu esquema YAML
59
+ DATA_DIR = CFG.get("storage", {}).get("root_dir", "data")
60
+ BACKEND_BASE_URL = CFG.get("api", {}).get("base_url", "http://localhost:8000")
61
+ USE_MOCK = bool(CFG.get("app", {}).get("use_mock", False)) # si no la tienes en el yaml, queda False
62
+ API_TOKEN = CFG.get("api", {}).get("token") or os.getenv("API_SHARED_TOKEN")
63
+
64
+ os.makedirs(DATA_DIR, exist_ok=True)
65
+ ensure_dirs(DATA_DIR)
66
+ DB_PATH = os.path.join(DATA_DIR, "app.db")
67
+ set_db_path(DB_PATH)
68
+ init_schema()
69
+
70
+ api = APIClient(BACKEND_BASE_URL, use_mock=USE_MOCK, data_dir=DATA_DIR, token=API_TOKEN)
71
+
72
+ st.set_page_config(page_title="Veureu — Audiodescripció", page_icon="🎬", layout="wide")
73
+
74
+ # --- Session: auth ---
75
+ # print("Usuarios disponibles:", get_all_users()) # Descomentar para depurar
76
+ if "user" not in st.session_state:
77
+ st.session_state.user = None # dict with {username, role, id(optional)}
78
+
79
+ def require_login():
80
+ if not st.session_state.user:
81
+ st.info("Por favor, inicia sesión para continuar.")
82
+ login_form()
83
+ st.stop()
84
+
85
+ def verify_password(password: str, pw_hash: str) -> bool:
86
+ try:
87
+ return bcrypt.verify(password, pw_hash)
88
+ except Exception:
89
+ return False
90
+
91
+ # --- Sidebar (only after login) ---
92
+ role = st.session_state.user["role"] if st.session_state.user else None
93
+ with st.sidebar:
94
+ st.title("Veureu")
95
+ if st.session_state.user:
96
+ st.write(f"Usuari: **{st.session_state.user['username']}** (rol: {st.session_state.user['role']})")
97
+ if st.button("Tancar sessió"):
98
+ st.session_state.user = None
99
+ st.rerun()
100
+ if st.session_state.user:
101
+ page = st.radio("Navegació", ["Analitzar video-transcripcions","Processar vídeo nou","Estadístiques"], index=0)
102
+ else:
103
+ page = None
104
+
105
+ # --- Pre-login screen ---
106
+ if not st.session_state.user:
107
+ st.title("Veureu — Audiodescripció")
108
+ def login_form():
109
+ st.subheader("Inici de sessió")
110
+ username = st.text_input("Usuari")
111
+ password = st.text_input("Contrasenya", type="password")
112
+ if st.button("Entrar", type="primary"):
113
+ row = get_user(username)
114
+ # Comprobar cualquiera de las dos columnas de hash de contraseña por compatibilidad
115
+ hash_to_check = row["password_hash"] or row["pw_hash"]
116
+ if row and hash_to_check and verify_password(password, hash_to_check):
117
+ st.session_state.user = {"id": row["id"], "username": row["username"], "role": row["role"]}
118
+ st.success(f"Benvingut/da, {row['username']}")
119
+ st.rerun()
120
+ else:
121
+ st.error("Credencials invàlides")
122
+ login_form()
123
+ st.stop()
124
+
125
+ # --- Pages ---
126
+ if page == "Processar vídeo nou":
127
+ require_login()
128
+ if role != "verd":
129
+ st.error("No tens permisos per processar nous vídeos. Canvia d'usuari o sol·licita permisos.")
130
+ st.stop()
131
+
132
+ st.header("Processar un nou clip de vídeo")
133
+
134
+ # Inicializar el estado de la página si no existe
135
+ if 'video_uploaded' not in st.session_state:
136
+ st.session_state.video_uploaded = None
137
+ if 'characters_detected' not in st.session_state:
138
+ st.session_state.characters_detected = None
139
+ if 'characters_saved' not in st.session_state:
140
+ st.session_state.characters_saved = False
141
+
142
+ # --- 1. Subida del vídeo ---
143
+ MAX_SIZE_MB = 20
144
+ MAX_DURATION_S = 240 # 4 minutos
145
+
146
+ uploaded_file = st.file_uploader("Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)", type=["mp4"], key="video_uploader")
147
+
148
+ if uploaded_file is not None:
149
+ # Resetear el estado si se sube un nuevo archivo
150
+ if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get('original_name'):
151
+ st.session_state.video_uploaded = {'original_name': uploaded_file.name, 'status': 'validating'}
152
+ st.session_state.characters_detected = None
153
+ st.session_state.characters_saved = False
154
+
155
+ # --- Validación y Procesamiento ---
156
+ if st.session_state.video_uploaded['status'] == 'validating':
157
+ is_valid = True
158
+ # 1. Validar tamaño
159
+ if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
160
+ st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
161
+ is_valid = False
162
+
163
+ if is_valid:
164
+ with st.spinner("Processant el vídeo..."):
165
+ # Guardar temporalmente para analizarlo
166
+ with open("temp_video.mp4", "wb") as f:
167
+ f.write(uploaded_file.getbuffer())
168
+
169
+ clip = VideoFileClip("temp_video.mp4")
170
+ duration = clip.duration
171
+
172
+ # 2. Validar y truncar duración
173
+ was_truncated = False
174
+ if duration > MAX_DURATION_S:
175
+ clip = clip.subclip(0, MAX_DURATION_S)
176
+ was_truncated = True
177
+
178
+ # Crear carpeta y guardar el vídeo final
179
+ video_name = Path(uploaded_file.name).stem
180
+ video_dir = Path("/tmp/data/videos") / video_name
181
+ video_dir.mkdir(parents=True, exist_ok=True)
182
+ final_video_path = video_dir / f"{video_name}.mp4"
183
+ clip.write_videofile(str(final_video_path), codec="libx264", audio_codec="aac")
184
+
185
+ clip.close()
186
+ os.remove("temp_video.mp4")
187
+
188
+ # Actualizar estado
189
+ st.session_state.video_uploaded.update({
190
+ 'status': 'processed',
191
+ 'path': str(final_video_path),
192
+ 'was_truncated': was_truncated
193
+ })
194
+ st.rerun()
195
+
196
+ # --- Mensajes de estado ---
197
+ if st.session_state.video_uploaded and st.session_state.video_uploaded['status'] == 'processed':
198
+ st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
199
+ if st.session_state.video_uploaded['was_truncated']:
200
+ st.warning(f"El vídeo s'ha truncat a {MAX_DURATION_S // 60} minuts.")
201
+
202
+ # --- 2. Detección de personajes ---
203
+ st.markdown("---")
204
+ col1, col2 = st.columns([1, 3])
205
+ with col1:
206
+ detect_button_disabled = st.session_state.video_uploaded is None
207
+ if st.button("Detectar Personatges", disabled=detect_button_disabled):
208
+ with st.spinner("Detectant personatges..."):
209
+ # Aquí iría la llamada a la API para detectar personajes
210
+ # Por ahora, usamos datos de ejemplo
211
+ st.session_state.characters_detected = [
212
+ {"id": "char1", "image_path": "init_data/placeholder.png", "description": "Dona amb cabell ros i ulleres"},
213
+ {"id": "char2", "image_path": "init_data/placeholder.png", "description": "Home amb barba i barret"},
214
+ ]
215
+ st.session_state.characters_saved = False # Resetear el estado de guardado
216
+
217
+ # --- 3. Formularios de personajes ---
218
+ if st.session_state.characters_detected:
219
+ st.subheader("Personatges detectats")
220
+ for char in st.session_state.characters_detected:
221
+ with st.form(key=f"form_{char['id']}"):
222
+ col1, col2 = st.columns(2)
223
+ with col1:
224
+ st.image(char['image_path'], width=150)
225
+
226
+ with col2:
227
+ st.caption(char['description'])
228
+ st.text_input("Nom del personatge", key=f"name_{char['id']}")
229
+ st.form_submit_button("Cercar")
230
+
231
+ st.markdown("---_**")
232
+
233
+ # --- 4. Guardar y Generar ---
234
+ col1, col2, col3 = st.columns([1,1,2])
235
+ with col1:
236
+ if st.button("Desar", type="primary"):
237
+ # Aquí iría la lógica para guardar los nombres de los personajes
238
+ st.session_state.characters_saved = True
239
+ st.success("Personatges desats correctament.")
240
+
241
+ with col2:
242
+ if st.session_state.characters_saved:
243
+ st.button("Generar Audiodescripció")
244
+
245
+ elif page == "Analitzar video-transcripcions":
246
+ require_login()
247
+ st.header("Analitzar video-transcripcions")
248
+ base_dir = Path("/tmp/data/videos")
249
+
250
+ if not base_dir.exists():
251
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
252
+ st.stop()
253
+
254
+ carpetes = [p.name for p in sorted(base_dir.iterdir()) if p.is_dir() and p.name != 'completed']
255
+ if not carpetes:
256
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
257
+ st.stop()
258
+
259
+ # --- Lógica de Estado y Selección ---
260
+
261
+ # Detectar si el vídeo principal ha cambiado para resetear el estado secundario
262
+ if 'current_video' not in st.session_state:
263
+ st.session_state.current_video = None
264
+
265
+ # Widget de selección de vídeo
266
+ seleccio = st.selectbox("Selecciona un vídeo (carpeta):", carpetes, index=None, placeholder="Tria una carpeta…")
267
+
268
+ if seleccio != st.session_state.current_video:
269
+ st.session_state.current_video = seleccio
270
+ # Forzar reseteo de los widgets dependientes
271
+ st.session_state.version_selector = None
272
+ st.session_state.add_ad_checkbox = False
273
+ st.rerun()
274
+
275
+ if not seleccio:
276
+ st.stop()
277
+
278
+ vid_dir = base_dir / seleccio
279
+ mp4s = sorted(vid_dir.glob("*.mp4"))
280
+
281
+ # --- Dibujado de la Interfaz ---
282
+ col_video, col_txt = st.columns([2, 1], gap="large")
283
+
284
+ with col_video:
285
+ # Selección de versión
286
+ subcarpetas_ad = [p.name for p in sorted(vid_dir.iterdir()) if p.is_dir()]
287
+ default_index_sub = subcarpetas_ad.index("Salamandra") if "Salamandra" in subcarpetas_ad else 0
288
+ subcarpeta_seleccio = st.selectbox(
289
+ "Selecciona una versió d'audiodescripció:", subcarpetas_ad,
290
+ index=default_index_sub if subcarpetas_ad else None,
291
+ placeholder="Tria una versió…" if subcarpetas_ad else "No hi ha versions",
292
+ key="version_selector"
293
+ )
294
+
295
+ # Lógica de vídeo AD
296
+ video_ad_path = vid_dir / subcarpeta_seleccio / "une_ad.mp4" if subcarpeta_seleccio else None
297
+ is_ad_video_available = video_ad_path is not None and video_ad_path.exists()
298
+
299
+ # Checkbox
300
+ add_ad_video = st.checkbox("Afegir audiodescripció", disabled=not is_ad_video_available, key="add_ad_checkbox")
301
+
302
+ # Decidir qué vídeo mostrar
303
+ video_to_show = None
304
+ if add_ad_video and is_ad_video_available:
305
+ video_to_show = video_ad_path
306
+ elif mp4s:
307
+ video_to_show = mp4s[0]
308
+
309
+ if video_to_show:
310
+ st.video(str(video_to_show))
311
+ else:
312
+ st.warning("No s'ha trobat cap fitxer **.mp4** a la carpeta seleccionada.")
313
+
314
+ st.markdown("---")
315
+
316
+ # Sección de ACCIONES
317
+ st.markdown("#### Accions")
318
+ c1, c2 = st.columns(2)
319
+ with c1:
320
+ if st.button("Reconstruir àudio amb narració lliure", use_container_width=True, key="rebuild_free_ad"):
321
+ if subcarpeta_seleccio:
322
+ free_ad_path = vid_dir / subcarpeta_seleccio / "free_ad.txt"
323
+ if free_ad_path.exists():
324
+ with st.spinner("Generant àudio de la narració lliure..."):
325
+ text_content = free_ad_path.read_text(encoding="utf-8")
326
+ voice = "central/grau" # Voz fijada
327
+ response = api.tts_matxa(text=text_content, voice=voice)
328
+ if "mp3_bytes" in response:
329
+ output_path = vid_dir / subcarpeta_seleccio / "free_ad.mp3"
330
+ save_bytes(output_path, response["mp3_bytes"])
331
+ st.success(f"Àudio generat i desat a: {output_path}")
332
+ else:
333
+ st.error(f"Error en la generació de l'àudio: {response.get('error', 'Desconegut')}")
334
+ else:
335
+ st.warning("No s'ha trobat el fitxer 'free_ad.txt' en aquesta versió.")
336
+
337
+ with c2:
338
+ if st.button("Reconstruir vídeo amb audiodescripció", use_container_width=True, key="rebuild_video_ad"):
339
+ if subcarpeta_seleccio and mp4s:
340
+ une_srt_path = vid_dir / subcarpeta_seleccio / "une_ad.srt"
341
+ video_original_path = mp4s[0]
342
+ if une_srt_path.exists():
343
+ with st.spinner("Reconstruint el vídeo amb l'audiodescripció... Aquesta operació pot trigar una estona."):
344
+ response = api.rebuild_video_with_ad(video_path=str(video_original_path), srt_path=str(une_srt_path))
345
+ if "video_bytes" in response:
346
+ output_path = vid_dir / subcarpeta_seleccio / "video_ad_rebuilt.mp4"
347
+ save_bytes(output_path, response["video_bytes"])
348
+ st.success(f"Vídeo reconstruït i desat a: {output_path}")
349
+ st.info("Pots visualitzar-lo activant la casella 'Afegir audiodescripció' i seleccionant el nou fitxer si cal.")
350
+ else:
351
+ st.error(f"Error en la reconstrucció del vídeo: {response.get('error', 'Desconegut')}")
352
+ else:
353
+ st.warning("No s'ha trobat el fitxer 'une_ad.srt' en aquesta versió.")
354
+
355
+
356
+ # --- Columna Derecha (Editor de texto y guardado) ---
357
+ with col_txt:
358
+ tipus_ad_options = ["narració lliure", "UNE-153010"]
359
+ tipus_ad_seleccio = st.selectbox("Fitxer d'audiodescripció a editar:", tipus_ad_options)
360
+
361
+ ad_filename = "free_ad.txt" if tipus_ad_seleccio == "narració lliure" else "une_ad.srt"
362
+
363
+ # Cargar el contenido del fichero seleccionado
364
+ text_content = ""
365
+ ad_path = None
366
+ if subcarpeta_seleccio:
367
+ ad_path = vid_dir / subcarpeta_seleccio / ad_filename
368
+ if ad_path.exists():
369
+ try:
370
+ text_content = ad_path.read_text(encoding="utf-8")
371
+ except Exception:
372
+ text_content = ad_path.read_text(errors="ignore")
373
+ else:
374
+ st.info(f"No s'ha trobat el fitxer **{ad_filename}**.")
375
+ else:
376
+ st.warning("Selecciona una versió per veure els fitxers.")
377
+
378
+ # Área de texto para edición
379
+ new_text = st.text_area(f"Contingut de {tipus_ad_seleccio}", value=text_content, height=500, key=f"editor_{seleccio}_{subcarpeta_seleccio}_{ad_filename}")
380
+
381
+ # Controles de reproducción de narración (selector de voz eliminado)
382
+ if st.button("▶️ Reproduir narració", use_container_width=True, disabled=not new_text.strip(), key="play_button_editor"):
383
+ with st.spinner("Generant àudio..."):
384
+ # Lógica de TTS con el texto del área
385
+ pass # Implementación de la llamada a la API TTS
386
+
387
+ # Botón de guardado
388
+ if st.button("Desar canvis", use_container_width=True, type="primary"):
389
+ if ad_path:
390
+ try:
391
+ ad_path.write_text(new_text, encoding="utf-8")
392
+ st.success(f"Fitxer **{ad_filename}** desat correctament.")
393
+ st.rerun()
394
+ except Exception as e:
395
+ st.error(f"No s'ha pogut desar el fitxer: {e}")
396
+ else:
397
+ st.error("No s'ha seleccionat una ruta de fitxer vàlida per desar.")
398
+
399
+
400
+ st.markdown("---")
401
+ st.subheader("Avaluació de la qualitat de l'audiodescripció")
402
+
403
+ c1, c2, c3 = st.columns(3)
404
+ with c1:
405
+ transcripcio = st.slider("Transcripció", 1, 10, 7)
406
+ identificacio = st.slider("Identificació de personatges", 1, 10, 7)
407
+ with c2:
408
+ localitzacions = st.slider("Localitzacions", 1, 10, 7)
409
+ activitats = st.slider("Activitats", 1, 10, 7)
410
+ with c3:
411
+ narracions = st.slider("Narracions", 1, 10, 7)
412
+ expressivitat = st.slider("Expressivitat", 1, 10, 7)
413
+
414
+ comments = st.text_area("Comentaris (opcional)", placeholder="Escriu els teus comentaris lliures…", height=120)
415
+
416
+ role = st.session_state.user["role"]
417
+ can_rate = role in ("verd", "groc", "blau")
418
+
419
+ if not can_rate:
420
+ st.info("El teu rol no permet enviar valoracions.")
421
+ else:
422
+ if st.button("Enviar valoració", type="primary", use_container_width=True):
423
+ try:
424
+ from database import add_feedback_ad
425
+ add_feedback_ad(
426
+ video_name=seleccio,
427
+ user_id=st.session_state.user["id"],
428
+ transcripcio=transcripcio,
429
+ identificacio=identificacio,
430
+ localitzacions=localitzacions,
431
+ activitats=activitats,
432
+ narracions=narracions,
433
+ expressivitat=expressivitat,
434
+ comments=comments or None
435
+ )
436
+ st.success("Gràcies! La teva valoració s'ha desat correctament.")
437
+ except Exception as e:
438
+ st.error(f"S'ha produït un error en desar la valoració: {e}")
439
+
440
+
441
+ elif page == "Estadístiques":
442
+ require_login()
443
+ st.header("Estadístiques")
444
+
445
+ from database import get_feedback_ad_stats
446
+ stats = get_feedback_ad_stats() # medias por vídeo + avg_global
447
+ if not stats:
448
+ st.caption("Encara no hi ha valoracions.")
449
+ st.stop()
450
+
451
+ import pandas as pd
452
+ df = pd.DataFrame(stats, columns=stats[0].keys())
453
+ ordre = st.radio("Ordre de rànquing", ["Descendent (millors primer)", "Ascendent (pitjors primer)"], horizontal=True)
454
+ if ordre.startswith("Asc"):
455
+ df = df.sort_values("avg_global", ascending=True)
456
+ else:
457
+ df = df.sort_values("avg_global", ascending=False)
458
+
459
+ st.subheader("Rànquing de vídeos")
460
+ st.dataframe(
461
+ df[["video_name","n","avg_global","avg_transcripcio","avg_identificacio","avg_localitzacions","avg_activitats","avg_narracions", "avg_expressivitat"]],
462
+ use_container_width=True
463
+ )
464
+