VeuReu commited on
Commit
d345661
·
verified ·
1 Parent(s): a8cfd8c

Upload 6 files

Browse files
app.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import yaml
3
+ import shutil
4
+ from pathlib import Path
5
+ try:
6
+ import tomllib
7
+ except ModuleNotFoundError: # Py<3.11
8
+ import tomli as tomllib
9
+ import streamlit as st
10
+
11
+ from database import set_db_path, init_schema
12
+ from api_client import APIClient
13
+ from utils import ensure_dirs
14
+ from auth import initialize_auth_system, render_login_form, render_sidebar, require_login
15
+ from mobile_verification import render_mobile_verification_screen, get_user_permissions
16
+ from compliance_client import compliance_client
17
+ from page_modules.process_video import render_process_video_page
18
+ from page_modules.analyze_transcriptions import render_analyze_transcriptions_page
19
+ from page_modules.statistics import render_statistics_page
20
+ from page_modules.validation import render_validation_page
21
+
22
+
23
+ # -- Move DB ---
24
+ os.environ["STREAMLIT_DATA_DIRECTORY"] = "/tmp/.streamlit"
25
+ Path("/tmp/.streamlit").mkdir(parents=True, exist_ok=True)
26
+ Path("/tmp/data").mkdir(parents=True, exist_ok=True)
27
+ source_db = "init_data/veureu.db"
28
+ target_db = "/tmp/data/app.db"
29
+ if not os.path.exists(target_db) and os.path.exists(source_db):
30
+ shutil.copy(source_db, target_db)
31
+
32
+ static_videos = Path(__file__).parent / "videos"
33
+ runtime_videos = Path("/tmp/data/videos")
34
+ if not runtime_videos.exists():
35
+ shutil.copytree(static_videos, runtime_videos, dirs_exist_ok=True)
36
+
37
+
38
+ # --- Config ---
39
+ def _load_yaml(path="config.yaml") -> dict:
40
+ with open(path, "r", encoding="utf-8") as f:
41
+ cfg = yaml.safe_load(f) or {}
42
+ # interpolación sencilla de ${VARS} si las usas en el YAML
43
+ def _subst(s: str) -> str:
44
+ return os.path.expandvars(s) if isinstance(s, str) else s
45
+
46
+ # aplica sustitución en los campos que te interesan
47
+ if "api" in cfg:
48
+ cfg["api"]["base_url"] = _subst(cfg["api"].get("base_url", ""))
49
+ cfg["api"]["token"] = _subst(cfg["api"].get("token", ""))
50
+
51
+ if "storage" in cfg and "root_dir" in cfg["storage"]:
52
+ cfg["storage"]["root_dir"] = _subst(cfg["storage"]["root_dir"])
53
+
54
+ if "sqlite" in cfg and "path" in cfg["sqlite"]:
55
+ cfg["sqlite"]["path"] = _subst(cfg["sqlite"]["path"])
56
+
57
+ return cfg
58
+
59
+ CFG = _load_yaml("config.yaml")
60
+
61
+ # Ajuste de variables según tu esquema YAML
62
+ DATA_DIR = CFG.get("storage", {}).get("root_dir", "data")
63
+ BACKEND_BASE_URL = CFG.get("api", {}).get("base_url", "http://localhost:8000")
64
+ USE_MOCK = bool(CFG.get("app", {}).get("use_mock", False)) # si no la tienes en el yaml, queda False
65
+ API_TOKEN = CFG.get("api", {}).get("token") or os.getenv("API_SHARED_TOKEN")
66
+
67
+ os.makedirs(DATA_DIR, exist_ok=True)
68
+ ensure_dirs(DATA_DIR)
69
+ DB_PATH = os.path.join(DATA_DIR, "app.db")
70
+ set_db_path(DB_PATH)
71
+ init_schema()
72
+
73
+ # Initialize authentication system and sync default users
74
+ initialize_auth_system(DB_PATH)
75
+
76
+ api = APIClient(BACKEND_BASE_URL, use_mock=USE_MOCK, data_dir=DATA_DIR, token=API_TOKEN)
77
+
78
+ st.set_page_config(page_title="Veureu — Audiodescripció", page_icon="🎬", layout="wide")
79
+
80
+ # Initialize session state for user
81
+ if "user" not in st.session_state:
82
+ st.session_state.user = None
83
+
84
+ # Render sidebar and get navigation
85
+ page, role = render_sidebar()
86
+
87
+ # Pre-login screen
88
+ if not st.session_state.user:
89
+ st.title("Veureu — Audiodescripció")
90
+ render_login_form()
91
+ st.stop()
92
+
93
+ # Post-login: Verificación por móvil si es necesaria
94
+ if st.session_state.user and 'sms_verified' not in st.session_state:
95
+ st.session_state.sms_verified = None
96
+
97
+ permissions = None
98
+ if st.session_state.user:
99
+ username = st.session_state.user['username']
100
+ role = st.session_state.user['role']
101
+
102
+ # Obtener permisos para ver si requiere SMS
103
+ permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
104
+
105
+ # Si requiere SMS y aún no está verificado/omitido, mostrar pantalla de verificación
106
+ if permissions["requires_sms"] and st.session_state.sms_verified is None:
107
+ result = render_mobile_verification_screen(username, role)
108
+ if result is None:
109
+ # Aún en proceso de verificación
110
+ st.stop()
111
+ # Si result es True o False, ya se ha completado/omitido y continúa
112
+
113
+ # --- Pages ---
114
+ if page == "Processar vídeo nou":
115
+ require_login(render_login_form)
116
+
117
+ permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
118
+ if not permissions["procesar_videos"]:
119
+ st.error("No tens permisos per processar nous vídeos. Verifica el teu mòbil per obtenir accés complet.")
120
+ st.stop()
121
+
122
+ render_process_video_page()
123
+
124
+ elif page == "Analitzar video-transcripcions":
125
+ require_login(render_login_form)
126
+ permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
127
+ render_analyze_transcriptions_page(api, permissions)
128
+
129
+ elif page == "Estadístiques":
130
+ require_login(render_login_form)
131
+ render_statistics_page()
132
+
133
+ elif page == "Validació":
134
+ require_login(render_login_form)
135
+ permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
136
+ render_validation_page(compliance_client, runtime_videos, permissions, username)
page_modules/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Modular page renderers for the Veureu Streamlit app."""
page_modules/analyze_transcriptions.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Analitzar video-transcripcions" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Dict
7
+
8
+ import streamlit as st
9
+
10
+ from utils import save_bytes
11
+
12
+
13
+ def render_analyze_transcriptions_page(api, permissions: Dict[str, bool]) -> None:
14
+ st.header("Analitzar video-transcripcions")
15
+ base_dir = Path("/tmp/data/videos")
16
+
17
+ if not base_dir.exists():
18
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
19
+ st.stop()
20
+
21
+ carpetes = [p.name for p in sorted(base_dir.iterdir()) if p.is_dir() and p.name != "completed"]
22
+ if not carpetes:
23
+ st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
24
+ st.stop()
25
+
26
+ if "current_video" not in st.session_state:
27
+ st.session_state.current_video = None
28
+
29
+ seleccio = st.selectbox("Selecciona un vídeo (carpeta):", carpetes, index=None, placeholder="Tria una carpeta…")
30
+
31
+ if seleccio != st.session_state.current_video:
32
+ st.session_state.current_video = seleccio
33
+ if "version_selector" in st.session_state:
34
+ del st.session_state["version_selector"]
35
+ st.session_state.add_ad_checkbox = False
36
+ st.rerun()
37
+
38
+ if not seleccio:
39
+ st.stop()
40
+
41
+ vid_dir = base_dir / seleccio
42
+ mp4s = sorted(vid_dir.glob("*.mp4"))
43
+
44
+ col_video, col_txt = st.columns([2, 1], gap="large")
45
+
46
+ with col_video:
47
+ subcarpetas_ad = [p.name for p in sorted(vid_dir.iterdir()) if p.is_dir()]
48
+ default_index_sub = subcarpetas_ad.index("Salamandra") if "Salamandra" in subcarpetas_ad else 0
49
+ subcarpeta_seleccio = st.selectbox(
50
+ "Selecciona una versió d'audiodescripció:",
51
+ subcarpetas_ad,
52
+ index=default_index_sub if subcarpetas_ad else None,
53
+ placeholder="Tria una versió…" if subcarpetas_ad else "No hi ha versions",
54
+ key="version_selector",
55
+ )
56
+
57
+ video_ad_path = vid_dir / subcarpeta_seleccio / "une_ad.mp4" if subcarpeta_seleccio else None
58
+ is_ad_video_available = video_ad_path is not None and video_ad_path.exists()
59
+
60
+ add_ad_video = st.checkbox(
61
+ "Afegir audiodescripció",
62
+ disabled=not is_ad_video_available,
63
+ key="add_ad_checkbox",
64
+ )
65
+
66
+ video_to_show = None
67
+ if add_ad_video and is_ad_video_available:
68
+ video_to_show = video_ad_path
69
+ elif mp4s:
70
+ video_to_show = mp4s[0]
71
+
72
+ if video_to_show:
73
+ st.video(str(video_to_show))
74
+ else:
75
+ st.warning("No s'ha trobat cap fitxer **.mp4** a la carpeta seleccionada.")
76
+
77
+ st.markdown("---")
78
+ st.markdown("#### Accions")
79
+ c1, c2 = st.columns(2)
80
+ with c1:
81
+ if st.button("Reconstruir àudio amb narració lliure", use_container_width=True, key="rebuild_free_ad"):
82
+ if subcarpeta_seleccio:
83
+ free_ad_path = vid_dir / subcarpeta_seleccio / "free_ad.txt"
84
+ if free_ad_path.exists():
85
+ with st.spinner("Generant àudio de la narració lliure..."):
86
+ text_content = free_ad_path.read_text(encoding="utf-8")
87
+ voice = "central/grau"
88
+ response = api.tts_matxa(text=text_content, voice=voice)
89
+ if "mp3_bytes" in response:
90
+ output_path = vid_dir / subcarpeta_seleccio / "free_ad.mp3"
91
+ save_bytes(output_path, response["mp3_bytes"])
92
+ st.success(f"Àudio generat i desat a: {output_path}")
93
+ else:
94
+ st.error(f"Error en la generació de l'àudio: {response.get('error', 'Desconegut')}")
95
+ else:
96
+ st.warning("No s'ha trobat el fitxer 'free_ad.txt' en aquesta versió.")
97
+
98
+ with c2:
99
+ if st.button("Reconstruir vídeo amb audiodescripció", use_container_width=True, key="rebuild_video_ad"):
100
+ if subcarpeta_seleccio and mp4s:
101
+ une_srt_path = vid_dir / subcarpeta_seleccio / "une_ad.srt"
102
+ video_original_path = mp4s[0]
103
+ if une_srt_path.exists():
104
+ with st.spinner(
105
+ "Reconstruint el vídeo amb l'audiodescripció... Aquesta operació pot trigar una estona."
106
+ ):
107
+ response = api.rebuild_video_with_ad(
108
+ video_path=str(video_original_path),
109
+ srt_path=str(une_srt_path),
110
+ )
111
+ if "video_bytes" in response:
112
+ output_path = vid_dir / subcarpeta_seleccio / "video_ad_rebuilt.mp4"
113
+ save_bytes(output_path, response["video_bytes"])
114
+ st.success(f"Vídeo reconstruït i desat a: {output_path}")
115
+ st.info(
116
+ "Pots visualitzar-lo activant la casella 'Afegir audiodescripció' i seleccionant el nou fitxer si cal."
117
+ )
118
+ else:
119
+ st.error(f"Error en la reconstrucció del vídeo: {response.get('error', 'Desconegut')}")
120
+ else:
121
+ st.warning("No s'ha trobat el fitxer 'une_ad.srt' en aquesta versió.")
122
+
123
+ with col_txt:
124
+ tipus_ad_options = ["narració lliure", "UNE-153010"]
125
+ tipus_ad_seleccio = st.selectbox("Fitxer d'audiodescripció a editar:", tipus_ad_options)
126
+
127
+ ad_filename = "free_ad.txt" if tipus_ad_seleccio == "narració lliure" else "une_ad.srt"
128
+
129
+ text_content = ""
130
+ ad_path = None
131
+ if subcarpeta_seleccio:
132
+ ad_path = vid_dir / subcarpeta_seleccio / ad_filename
133
+ if ad_path.exists():
134
+ try:
135
+ text_content = ad_path.read_text(encoding="utf-8")
136
+ except Exception:
137
+ text_content = ad_path.read_text(errors="ignore")
138
+ else:
139
+ st.info(f"No s'ha trobat el fitxer **{ad_filename}**.")
140
+ else:
141
+ st.warning("Selecciona una versió per veure els fitxers.")
142
+
143
+ new_text = st.text_area(
144
+ f"Contingut de {tipus_ad_seleccio}",
145
+ value=text_content,
146
+ height=500,
147
+ key=f"editor_{seleccio}_{subcarpeta_seleccio}_{ad_filename}",
148
+ )
149
+
150
+ if st.button(
151
+ "▶️ Reproduir narració",
152
+ use_container_width=True,
153
+ disabled=not new_text.strip(),
154
+ key="play_button_editor",
155
+ ):
156
+ with st.spinner("Generant àudio..."):
157
+ pass
158
+
159
+ if st.button("Desar canvis", use_container_width=True, type="primary"):
160
+ if ad_path:
161
+ try:
162
+ ad_path.write_text(new_text, encoding="utf-8")
163
+ st.success(f"Fitxer **{ad_filename}** desat correctament.")
164
+ st.rerun()
165
+ except Exception as e:
166
+ st.error(f"No s'ha pogut desar el fitxer: {e}")
167
+ else:
168
+ st.error("No s'ha seleccionat una ruta de fitxer vàlida per desar.")
169
+
170
+ st.markdown("---")
171
+ st.subheader("Avaluació de la qualitat de l'audiodescripció")
172
+
173
+ can_rate = permissions.get("valorar", False)
174
+ controls_disabled = not can_rate
175
+
176
+ c1, c2, c3 = st.columns(3)
177
+ with c1:
178
+ transcripcio = st.slider("Transcripció", 1, 10, 7, disabled=controls_disabled)
179
+ identificacio = st.slider("Identificació de personatges", 1, 10, 7, disabled=controls_disabled)
180
+ with c2:
181
+ localitzacions = st.slider("Localitzacions", 1, 10, 7, disabled=controls_disabled)
182
+ activitats = st.slider("Activitats", 1, 10, 7, disabled=controls_disabled)
183
+ with c3:
184
+ narracions = st.slider("Narracions", 1, 10, 7, disabled=controls_disabled)
185
+ expressivitat = st.slider("Expressivitat", 1, 10, 7, disabled=controls_disabled)
186
+
187
+ comments = st.text_area(
188
+ "Comentaris (opcional)",
189
+ placeholder="Escriu els teus comentaris lliures…",
190
+ height=120,
191
+ disabled=controls_disabled,
192
+ )
193
+
194
+ if not can_rate:
195
+ st.info("El teu rol no permet enviar valoracions.")
196
+ else:
197
+ if st.button("Enviar valoració", type="primary", use_container_width=True):
198
+ try:
199
+ from database import add_feedback_ad
200
+
201
+ add_feedback_ad(
202
+ video_name=seleccio,
203
+ user_id=st.session_state.user["id"],
204
+ transcripcio=transcripcio,
205
+ identificacio=identificacio,
206
+ localitzacions=localitzacions,
207
+ activitats=activitats,
208
+ narracions=narracions,
209
+ expressivitat=expressivitat,
210
+ comments=comments or None,
211
+ )
212
+ st.success("Gràcies! La teva valoració s'ha desat correctament.")
213
+ except Exception as e:
214
+ st.error(f"S'ha produït un error en desar la valoració: {e}")
page_modules/process_video.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Processar vídeo nou" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import streamlit as st
11
+
12
+
13
+ def _get_video_duration(path: str) -> float:
14
+ """Return video duration in seconds using ffprobe, ffmpeg or OpenCV as fallback."""
15
+ cmd = [
16
+ "ffprobe",
17
+ "-v",
18
+ "error",
19
+ "-show_entries",
20
+ "format=duration",
21
+ "-of",
22
+ "default=noprint_wrappers=1:nokey=1",
23
+ path,
24
+ ]
25
+ try:
26
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
27
+ return float(result.stdout.strip())
28
+ except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
29
+ pass
30
+
31
+ if shutil.which("ffmpeg"):
32
+ try:
33
+ ffmpeg_cmd = ["ffmpeg", "-i", path]
34
+ result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=False)
35
+ output = result.stderr or result.stdout or ""
36
+ match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", output)
37
+ if match:
38
+ hours, minutes, seconds = match.groups()
39
+ total_seconds = (int(hours) * 3600) + (int(minutes) * 60) + float(seconds)
40
+ return float(total_seconds)
41
+ except FileNotFoundError:
42
+ pass
43
+
44
+ # Últim recurs: intentar amb OpenCV si està disponible
45
+ try:
46
+ import cv2
47
+
48
+ cap = cv2.VideoCapture(path)
49
+ if cap.isOpened():
50
+ fps = cap.get(cv2.CAP_PROP_FPS) or 0
51
+ frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
52
+ cap.release()
53
+
54
+ if fps > 0 and frame_count > 0:
55
+ return float(frame_count / fps)
56
+ else:
57
+ cap.release()
58
+ except Exception:
59
+ pass
60
+
61
+ return 0.0
62
+
63
+
64
+ def _transcode_video(input_path: str, output_path: str, max_duration: int | None = None) -> None:
65
+ cmd = ["ffmpeg", "-y", "-i", input_path]
66
+ if max_duration is not None:
67
+ cmd += ["-t", str(max_duration)]
68
+ cmd += [
69
+ "-c:v",
70
+ "libx264",
71
+ "-preset",
72
+ "veryfast",
73
+ "-crf",
74
+ "23",
75
+ "-c:a",
76
+ "aac",
77
+ "-movflags",
78
+ "+faststart",
79
+ output_path,
80
+ ]
81
+ result = subprocess.run(cmd, capture_output=True, text=True)
82
+ if result.returncode != 0:
83
+ raise RuntimeError(result.stderr.strip() or "ffmpeg failed")
84
+
85
+
86
+ def render_process_video_page() -> None:
87
+ st.header("Processar un nou clip de vídeo")
88
+
89
+ # Inicializar el estado de la página si no existe
90
+ if "video_uploaded" not in st.session_state:
91
+ st.session_state.video_uploaded = None
92
+ if "characters_detected" not in st.session_state:
93
+ st.session_state.characters_detected = None
94
+ if "characters_saved" not in st.session_state:
95
+ st.session_state.characters_saved = False
96
+
97
+ # --- 1. Subida del vídeo ---
98
+ MAX_SIZE_MB = 20
99
+ MAX_DURATION_S = 240 # 4 minutos
100
+
101
+ uploaded_file = st.file_uploader(
102
+ "Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)",
103
+ type=["mp4"],
104
+ key="video_uploader",
105
+ )
106
+
107
+ if uploaded_file is not None:
108
+ # Resetear el estado si se sube un nuevo archivo
109
+ if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get(
110
+ "original_name"
111
+ ):
112
+ st.session_state.video_uploaded = {"original_name": uploaded_file.name, "status": "validating"}
113
+ st.session_state.characters_detected = None
114
+ st.session_state.characters_saved = False
115
+
116
+ if st.session_state.video_uploaded["status"] == "validating":
117
+ is_valid = True
118
+ if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
119
+ st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
120
+ is_valid = False
121
+
122
+ if is_valid:
123
+ with st.spinner("Processant el vídeo..."):
124
+ temp_path = Path("temp_video.mp4")
125
+ with temp_path.open("wb") as f:
126
+ f.write(uploaded_file.getbuffer())
127
+
128
+ was_truncated = False
129
+ final_video_path = None
130
+ try:
131
+ duration = _get_video_duration(str(temp_path))
132
+ if not duration:
133
+ st.error("No s'ha pogut obtenir la durada del vídeo.")
134
+ is_valid = False
135
+
136
+ if is_valid:
137
+ if duration > MAX_DURATION_S:
138
+ was_truncated = True
139
+
140
+ video_name = Path(uploaded_file.name).stem
141
+ video_dir = Path("/tmp/data/videos") / video_name
142
+ video_dir.mkdir(parents=True, exist_ok=True)
143
+ final_video_path = video_dir / f"{video_name}.mp4"
144
+
145
+ try:
146
+ _transcode_video(
147
+ str(temp_path),
148
+ str(final_video_path),
149
+ MAX_DURATION_S if was_truncated else None,
150
+ )
151
+ except RuntimeError as exc:
152
+ st.error(f"No s'ha pogut processar el vídeo: {exc}")
153
+ is_valid = False
154
+
155
+ if is_valid and final_video_path is not None:
156
+ st.session_state.video_uploaded.update(
157
+ {
158
+ "status": "processed",
159
+ "path": str(final_video_path),
160
+ "was_truncated": was_truncated,
161
+ }
162
+ )
163
+ st.rerun()
164
+ finally:
165
+ if temp_path.exists():
166
+ temp_path.unlink()
167
+
168
+ if st.session_state.video_uploaded and st.session_state.video_uploaded["status"] == "processed":
169
+ st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
170
+ if st.session_state.video_uploaded["was_truncated"]:
171
+ st.warning("El vídeo s'ha truncat a 4 minuts.")
172
+
173
+ st.markdown("---")
174
+ col1, col2 = st.columns([1, 3])
175
+ with col1:
176
+ detect_button_disabled = st.session_state.video_uploaded is None
177
+ if st.button("Detectar Personatges", disabled=detect_button_disabled):
178
+ with st.spinner("Detectant personatges..."):
179
+ st.session_state.characters_detected = [
180
+ {
181
+ "id": "char1",
182
+ "image_path": "init_data/placeholder.png",
183
+ "description": "Dona amb cabell ros i ulleres",
184
+ },
185
+ {
186
+ "id": "char2",
187
+ "image_path": "init_data/placeholder.png",
188
+ "description": "Home amb barba i barret",
189
+ },
190
+ ]
191
+ st.session_state.characters_saved = False
192
+
193
+ if st.session_state.characters_detected:
194
+ st.subheader("Personatges detectats")
195
+ for char in st.session_state.characters_detected:
196
+ with st.form(key=f"form_{char['id']}"):
197
+ col1, col2 = st.columns(2)
198
+ with col1:
199
+ st.image(char["image_path"], width=150)
200
+ with col2:
201
+ st.caption(char["description"])
202
+ st.text_input("Nom del personatge", key=f"name_{char['id']}")
203
+ st.form_submit_button("Cercar")
204
+
205
+ st.markdown("---_**")
206
+
207
+ col1, col2, col3 = st.columns([1, 1, 2])
208
+ with col1:
209
+ if st.button("Desar", type="primary"):
210
+ st.session_state.characters_saved = True
211
+ st.success("Personatges desats correctament.")
212
+
213
+ with col2:
214
+ if st.session_state.characters_saved:
215
+ st.button("Generar Audiodescripció")
page_modules/statistics.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Estadístiques" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ import streamlit as st
7
+
8
+ from database import get_feedback_ad_stats
9
+
10
+
11
+ def render_statistics_page() -> None:
12
+ st.header("Estadístiques")
13
+
14
+ stats = get_feedback_ad_stats()
15
+ if not stats:
16
+ st.caption("Encara no hi ha valoracions.")
17
+ st.stop()
18
+
19
+ df = pd.DataFrame(stats, columns=stats[0].keys())
20
+ ordre = st.radio(
21
+ "Ordre de rànquing",
22
+ ["Descendent (millors primer)", "Ascendent (pitjors primer)"],
23
+ horizontal=True,
24
+ )
25
+ if ordre.startswith("Asc"):
26
+ df = df.sort_values("avg_global", ascending=True)
27
+ else:
28
+ df = df.sort_values("avg_global", ascending=False)
29
+
30
+ st.subheader("Rànquing de vídeos")
31
+ st.dataframe(
32
+ df[
33
+ [
34
+ "video_name",
35
+ "n",
36
+ "avg_global",
37
+ "avg_transcripcio",
38
+ "avg_identificacio",
39
+ "avg_localitzacions",
40
+ "avg_activitats",
41
+ "avg_narracions",
42
+ "avg_expressivitat",
43
+ ]
44
+ ],
45
+ use_container_width=True,
46
+ )
page_modules/validation.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Validació" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Dict
8
+
9
+ import streamlit as st
10
+
11
+
12
+ def _build_candidates(runtime_videos: Path) -> Path:
13
+ candidates = [
14
+ runtime_videos,
15
+ Path(__file__).resolve().parent.parent / "videos",
16
+ Path.cwd() / "videos",
17
+ ]
18
+ for candidate in candidates:
19
+ if candidate.exists():
20
+ return candidate
21
+ return candidates[0]
22
+
23
+
24
+ def render_validation_page(
25
+ compliance_client,
26
+ runtime_videos: Path,
27
+ permissions: Dict[str, bool],
28
+ username: str,
29
+ ) -> None:
30
+ if not permissions.get("validar", False):
31
+ st.warning("⚠️ No tens permisos per accedir a aquesta secció de validació.")
32
+ st.stop()
33
+
34
+ st.header("🔍 Validació de Vídeos")
35
+
36
+ tab_videos, tab_ads = st.tabs(["📹 Validar Vídeos", "🎬 Validar Audiodescripcions"])
37
+
38
+ base_dir = _build_candidates(runtime_videos)
39
+ if not base_dir.exists():
40
+ st.info("📝 No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
41
+ st.stop()
42
+
43
+ with tab_videos:
44
+ st.subheader("📹 Validar Vídeos Pujats")
45
+
46
+ video_folders = []
47
+ for folder in sorted(base_dir.iterdir()):
48
+ if folder.is_dir() and folder.name != "completed":
49
+ video_files = list(folder.glob("*.mp4")) + list(folder.glob("*.avi")) + list(folder.glob("*.mov"))
50
+ if video_files:
51
+ mod_time = folder.stat().st_mtime
52
+ fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
53
+ video_folders.append(
54
+ {
55
+ "name": folder.name,
56
+ "path": str(folder),
57
+ "created_at": fecha,
58
+ "video_files": video_files,
59
+ }
60
+ )
61
+
62
+ if not video_folders:
63
+ st.info("📝 No hi ha vídeos pujats pendents de validació.")
64
+ else:
65
+ opciones_video = [f"{video['name']} - {video['created_at']}" for video in video_folders]
66
+ seleccion = st.selectbox(
67
+ "Selecciona un vídeo per validar:",
68
+ opciones_video,
69
+ index=0 if opciones_video else None,
70
+ )
71
+
72
+ if seleccion:
73
+ indice = opciones_video.index(seleccion)
74
+ video_seleccionat = video_folders[indice]
75
+
76
+ col1, col2 = st.columns([2, 1])
77
+
78
+ with col1:
79
+ st.markdown("### 📹 Informació del Vídeo")
80
+ st.markdown(f"**Nom:** {video_seleccionat['name']}")
81
+ st.markdown(f"**Data:** {video_seleccionat['created_at']}")
82
+ st.markdown(f"**Arxius:** {len(video_seleccionat['video_files'])} vídeos trobats")
83
+
84
+ if video_seleccionat["video_files"]:
85
+ video_path = str(video_seleccionat["video_files"][0])
86
+ st.markdown("**Vídeo principal:**")
87
+ st.video(video_path)
88
+ else:
89
+ st.warning("⚠️ No s'han trobat arxius de vídeo.")
90
+
91
+ with col2:
92
+ st.markdown("### 🔍 Accions de Validació")
93
+
94
+ col_btn1, col_btn2 = st.columns(2)
95
+
96
+ with col_btn1:
97
+ if st.button("✅ Acceptar", type="primary", key=f"accept_video_{video_seleccionat['name']}"):
98
+ success = compliance_client.record_validator_decision(
99
+ document_id=f"video_{video_seleccionat['name']}",
100
+ validator_email=f"{username}@veureu.local",
101
+ decision="acceptat",
102
+ comments=f"Vídeo validat per {username}",
103
+ )
104
+ if success:
105
+ st.success("✅ Vídeo acceptat i registrat al servei de compliance")
106
+ else:
107
+ st.error("❌ Error registrant el veredicte")
108
+
109
+ with col_btn2:
110
+ if st.button("❌ Rebutjar", type="secondary", key=f"reject_video_{video_seleccionat['name']}" ):
111
+ success = compliance_client.record_validator_decision(
112
+ document_id=f"video_{video_seleccionat['name']}",
113
+ validator_email=f"{username}@veureu.local",
114
+ decision="rebutjat",
115
+ comments=f"Vídeo rebutjat per {username}",
116
+ )
117
+ if success:
118
+ st.success("✅ Vídeo rebutjat i registrat al servei de compliance")
119
+ else:
120
+ st.error("❌ Error registrant el veredicte")
121
+
122
+ with tab_ads:
123
+ st.subheader("🎬 Validar Audiodescripcions")
124
+
125
+ videos_con_ad = []
126
+ if base_dir.exists():
127
+ for folder in sorted(base_dir.iterdir()):
128
+ if folder.is_dir() and folder.name != "completed":
129
+ for subfolder_name in ["MoE", "Salamandra"]:
130
+ subfolder = folder / subfolder_name
131
+ if subfolder.exists():
132
+ ad_files = list(subfolder.glob("*_ad.txt")) + list(subfolder.glob("*_ad.srt"))
133
+ if ad_files:
134
+ mod_time = folder.stat().st_mtime
135
+ fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
136
+ videos_con_ad.append(
137
+ {
138
+ "name": folder.name,
139
+ "path": str(folder),
140
+ "created_at": fecha,
141
+ "ad_files": ad_files,
142
+ "ad_folder": str(subfolder),
143
+ }
144
+ )
145
+
146
+ if not videos_con_ad:
147
+ st.info("📝 No hi ha audiodescripcions pendents de validació.")
148
+ else:
149
+ videos_ad_ordenats = sorted(videos_con_ad, key=lambda x: x["created_at"], reverse=True)
150
+ opciones_ad = [f"{video['name']} - {video['created_at']}" for video in videos_ad_ordenats]
151
+
152
+ seleccion_ad = st.selectbox(
153
+ "Selecciona una audiodescripció per validar:",
154
+ opciones_ad,
155
+ index=0 if opciones_ad else None,
156
+ )
157
+
158
+ if seleccion_ad:
159
+ indice = opciones_ad.index(seleccion_ad)
160
+ video_seleccionat = videos_ad_ordenats[indice]
161
+
162
+ col1, col2 = st.columns([2, 1])
163
+
164
+ with col1:
165
+ st.markdown("### 🎬 Informació de l'Audiodescripció")
166
+ st.markdown(f"**Vídeo:** {video_seleccionat['name']}")
167
+ st.markdown(f"**Data:** {video_seleccionat['created_at']}")
168
+ st.markdown(f"**Carpeta:** {Path(video_seleccionat['ad_folder']).name}")
169
+ st.markdown(f"**Arxius:** {len(video_seleccionat['ad_files'])} audiodescripcions trobades")
170
+
171
+ if video_seleccionat["ad_files"]:
172
+ ad_path = video_seleccionat["ad_files"][0]
173
+ st.markdown(f"#### 📄 Contingut ({ad_path.name}):")
174
+ try:
175
+ texto = ad_path.read_text(encoding="utf-8")
176
+ except Exception:
177
+ texto = ad_path.read_text(errors="ignore")
178
+ st.text_area("Contingut de l'audiodescripció:", texto, height=300, disabled=True)
179
+ else:
180
+ st.warning("⚠️ No s'han trobat arxius d'audiodescripció.")
181
+
182
+ with col2:
183
+ st.markdown("### 🔍 Accions de Validació")
184
+
185
+ col_btn1, col_btn2 = st.columns(2)
186
+
187
+ with col_btn1:
188
+ if st.button("✅ Acceptar", type="primary", key=f"accept_ad_{video_seleccionat['name']}"):
189
+ success = compliance_client.record_validator_decision(
190
+ document_id=f"ad_{video_seleccionat['name']}",
191
+ validator_email=f"{username}@veureu.local",
192
+ decision="acceptat",
193
+ comments=f"Audiodescripció validada per {username}",
194
+ )
195
+ if success:
196
+ st.success("✅ Audiodescripció acceptada i registrada al servei de compliance")
197
+ else:
198
+ st.error("❌ Error registrant el veredicte")
199
+
200
+ with col_btn2:
201
+ if st.button("❌ Rebutjar", type="secondary", key=f"reject_ad_{video_seleccionat['name']}" ):
202
+ success = compliance_client.record_validator_decision(
203
+ document_id=f"ad_{video_seleccionat['name']}",
204
+ validator_email=f"{username}@veureu.local",
205
+ decision="rebutjat",
206
+ comments=f"Audiodescripció rebutjada per {username}",
207
+ )
208
+ if success:
209
+ st.success("✅ Audiodescripció rebutjada i registrada al servei de compliance")
210
+ else:
211
+ st.error("❌ Error registrant el veredicte")
212
+
213
+ st.markdown("---")
214
+ st.markdown("### ℹ️ Informació del Procés de Validació")
215
+ st.markdown(
216
+ """
217
+ - **Tots els veredictes** es registren al servei de compliance per garantir la traçabilitat
218
+ - **Cada validació** inclou veredicte, nom del vídeo i validador responsable
219
+ - **Els registres** compleixen amb la normativa AI Act i GDPR
220
+ """
221
+ )