VeuReu commited on
Commit
f07c3b1
·
verified ·
1 Parent(s): 6f1a568

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +462 -0
app.py ADDED
@@ -0,0 +1,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
+ 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
+