VeuReu commited on
Commit
d98a2a5
·
1 Parent(s): dd240fd

Upload 5 files

Browse files
page_modules/__init__.py CHANGED
@@ -1 +1 @@
1
- """Modular page renderers for the Veureu Streamlit app."""
 
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 CHANGED
@@ -1,1350 +1,215 @@
1
- """UI logic for the "Processar vídeo nou" page - Recovered from backup with full functionality."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- import shutil
7
- import subprocess
8
- import os
9
- import time
10
- import tempfile
11
- import hashlib
12
- from pathlib import Path
13
- import sys
14
- from datetime import datetime
15
- import yaml
16
-
17
- import streamlit as st
18
- from PIL import Image, ImageDraw
19
- from databases import log_event, has_video_approval_event
20
- from compliance_client import compliance_client
21
- from persistent_data_gate import ensure_temp_databases, _load_data_origin
22
-
23
-
24
- def get_all_catalan_names():
25
- """Retorna tots els noms catalans disponibles."""
26
- noms_home = ["Jordi", "Marc", "Pau", "Pere", "Joan", "Josep", "David", "Àlex", "Guillem", "Albert",
27
- "Arnau", "Martí", "Bernat", "Oriol", "Roger", "Pol", "Lluís", "Sergi", "Carles", "Xavier"]
28
- noms_dona = ["Maria", "Anna", "Laura", "Marta", "Cristina", "Núria", "Montserrat", "Júlia", "Sara", "Carla",
29
- "Alba", "Elisabet", "Rosa", "Gemma", "Sílvia", "Teresa", "Irene", "Laia", "Marina", "Bet"]
30
- return noms_home, noms_dona
31
-
32
-
33
- def _log(msg: str) -> None:
34
- """Helper de logging a stderr amb timestamp (coherent amb auth.py)."""
35
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
36
- sys.stderr.write(f"[{ts}] {msg}\n")
37
- sys.stderr.flush()
38
-
39
-
40
- def get_catalan_name_for_speaker(speaker_label: int, used_names_home: list = None, used_names_dona: list = None) -> str:
41
- """Genera un nom català per a un speaker, reutilitzant noms de caras si estan disponibles."""
42
- noms_home, noms_dona = get_all_catalan_names()
43
-
44
- if used_names_home is None:
45
- used_names_home = []
46
- if used_names_dona is None:
47
- used_names_dona = []
48
-
49
- is_male = (speaker_label % 2 == 0)
50
-
51
- if is_male:
52
- if used_names_home:
53
- idx = speaker_label // 2
54
- return used_names_home[idx % len(used_names_home)]
55
- else:
56
- hash_val = hash(f"speaker_{speaker_label}")
57
- return noms_home[abs(hash_val) % len(noms_home)]
58
- else:
59
- if used_names_dona:
60
- idx = speaker_label // 2
61
- return used_names_dona[idx % len(used_names_dona)]
62
- else:
63
- hash_val = hash(f"speaker_{speaker_label}")
64
- return noms_dona[abs(hash_val) % len(noms_dona)]
65
-
66
-
67
- def _get_video_duration(path: str) -> float:
68
- """Return video duration in seconds using ffprobe, ffmpeg or OpenCV as fallback."""
69
- cmd = [
70
- "ffprobe",
71
- "-v",
72
- "error",
73
- "-show_entries",
74
- "format=duration",
75
- "-of",
76
- "default=noprint_wrappers=1:nokey=1",
77
- path,
78
- ]
79
- try:
80
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
81
- return float(result.stdout.strip())
82
- except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
83
- pass
84
-
85
- if shutil.which("ffmpeg"):
86
- try:
87
- ffmpeg_cmd = ["ffmpeg", "-i", path]
88
- result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=False)
89
- output = result.stderr or result.stdout or ""
90
- match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", output)
91
- if match:
92
- hours, minutes, seconds = match.groups()
93
- total_seconds = (int(hours) * 3600) + (int(minutes) * 60) + float(seconds)
94
- return float(total_seconds)
95
- except FileNotFoundError:
96
- pass
97
-
98
- # Últim recurs: intentar amb OpenCV si està disponible
99
- try:
100
- import cv2
101
-
102
- cap = cv2.VideoCapture(path)
103
- if cap.isOpened():
104
- fps = cap.get(cv2.CAP_PROP_FPS) or 0
105
- frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
106
- cap.release()
107
-
108
- if fps > 0 and frame_count > 0:
109
- return float(frame_count / fps)
110
- else:
111
- cap.release()
112
- except Exception:
113
- pass
114
-
115
- return 0.0
116
-
117
-
118
- def _transcode_video(input_path: str, output_path: str, max_duration: int | None = None) -> None:
119
- cmd = ["ffmpeg", "-y", "-i", input_path]
120
- if max_duration is not None:
121
- cmd += ["-t", str(max_duration)]
122
- cmd += [
123
- "-c:v",
124
- "libx264",
125
- "-preset",
126
- "veryfast",
127
- "-crf",
128
- "23",
129
- "-c:a",
130
- "aac",
131
- "-movflags",
132
- "+faststart",
133
- output_path,
134
- ]
135
- result = subprocess.run(cmd, capture_output=True, text=True)
136
- if result.returncode != 0:
137
- raise RuntimeError(result.stderr.strip() or "ffmpeg failed")
138
-
139
-
140
- def render_process_video_page(api, backend_base_url: str) -> None:
141
- st.header("Processar un nou clip de vídeo")
142
-
143
- # Llegir flag de seguretat per a validació manual des de config.yaml
144
- base_dir = Path(__file__).parent.parent
145
- config_path = base_dir / "config.yaml"
146
- manual_validation_enabled = True
147
- try:
148
- if config_path.exists():
149
- with config_path.open("r", encoding="utf-8") as f:
150
- cfg = yaml.safe_load(f) or {}
151
- security_cfg = cfg.get("security", {}) or {}
152
- manual_validation_enabled = bool(security_cfg.get("manual_validation_enabed", True))
153
- except Exception:
154
- manual_validation_enabled = True
155
-
156
- # CSS para estabilizar carruseles y evitar vibración del layout
157
- st.markdown("""
158
- <style>
159
- /* Contenedor de imagen con aspect ratio fijo para evitar saltos */
160
- .stImage {
161
- min-height: 200px;
162
- max-height: 250px;
163
- display: flex;
164
- align-items: center;
165
- justify-content: center;
166
- overflow: hidden;
167
- }
168
-
169
- /* Imágenes con dimensiones consistentes y sin vibración */
170
- .stImage > img {
171
- max-width: 100%;
172
- height: auto;
173
- object-fit: contain;
174
- display: block;
175
- }
176
-
177
- /* Estabilizar reproductor de audio con altura fija */
178
- .stAudio {
179
- min-height: 54px;
180
- max-height: 80px;
181
- }
182
-
183
- /* Caption con altura fija */
184
- .stCaption {
185
- min-height: 20px;
186
- }
187
-
188
- /* Evitar transiciones que causen vibración en inputs */
189
- .stTextInput > div, .stTextArea > div {
190
- transition: none !important;
191
- }
192
-
193
- /* Botones de navegación con tamaño consistente */
194
- .stButton button {
195
- transition: background-color 0.2s, color 0.2s;
196
- min-height: 38px;
197
- white-space: nowrap;
198
- }
199
-
200
- /* Columnas con ancho fijo para evitar reflow horizontal */
201
- div[data-testid="column"] {
202
- min-width: 0 !important;
203
- flex-shrink: 0 !important;
204
- }
205
-
206
- div[data-testid="column"] > div {
207
- contain: layout style;
208
- min-width: 0;
209
- }
210
-
211
- /* Prevenir vibración horizontal en contenedores de columnas */
212
- [data-testid="stHorizontalBlock"] {
213
- gap: 1rem !important;
214
- }
215
-
216
- [data-testid="stHorizontalBlock"] > div {
217
- flex-shrink: 0 !important;
218
- }
219
-
220
- /* Prevenir cambios de layout al cargar contenido */
221
- [data-testid="stVerticalBlock"] > div {
222
- will-change: auto;
223
- }
224
-
225
- /* Forzar que las columnas mantengan su proporción sin vibrar */
226
- .row-widget.stHorizontalBlock {
227
- width: 100% !important;
228
- }
229
- </style>
230
- """, unsafe_allow_html=True)
231
-
232
- msg_detect = st.empty()
233
- msg_finalize = st.empty()
234
- msg_ad = st.empty()
235
-
236
- # Inicializar el estado de la página si no existe
237
- if "video_uploaded" not in st.session_state:
238
- st.session_state.video_uploaded = None
239
- if "characters_detected" not in st.session_state:
240
- st.session_state.characters_detected = None
241
- if "audio_segments" not in st.session_state:
242
- st.session_state.audio_segments = None
243
- if "voice_labels" not in st.session_state:
244
- st.session_state.voice_labels = None
245
- if "face_labels" not in st.session_state:
246
- st.session_state.face_labels = None
247
- if "scene_clusters" not in st.session_state:
248
- st.session_state.scene_clusters = None
249
- if "scene_detection_done" not in st.session_state:
250
- st.session_state.scene_detection_done = False
251
- if "detect_done" not in st.session_state:
252
- st.session_state.detect_done = False
253
- if "casting_finalized" not in st.session_state:
254
- st.session_state.casting_finalized = False
255
- if "video_name_from_engine" not in st.session_state:
256
- st.session_state.video_name_from_engine = None
257
- if "diarization_info" not in st.session_state:
258
- st.session_state.diarization_info = {}
259
- if "characters_saved" not in st.session_state:
260
- st.session_state.characters_saved = False
261
- if "video_requires_validation" not in st.session_state:
262
- st.session_state.video_requires_validation = False
263
- if "video_validation_approved" not in st.session_state:
264
- st.session_state.video_validation_approved = False
265
-
266
- # --- 1. Subida del vídeo ---
267
- MAX_SIZE_MB = 20
268
- MAX_DURATION_S = 240 # 4 minutos
269
-
270
- # Selector de visibilitat (privat/públic), a la dreta del uploader
271
- if "video_visibility" not in st.session_state:
272
- st.session_state.video_visibility = "Privat"
273
-
274
- col_upload, col_vis = st.columns([3, 1])
275
- with col_upload:
276
- uploaded_file = st.file_uploader(
277
- "Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)",
278
- type=["mp4"],
279
- key="video_uploader",
280
- )
281
- with col_vis:
282
- disabled_vis = st.session_state.video_uploaded is not None
283
- # Manté el valor triat abans de la pujada; després queda deshabilitat
284
- options = ["Privat", "Públic"]
285
- current = st.session_state.get("video_visibility", "Privat")
286
- try:
287
- idx = options.index(current)
288
- except ValueError:
289
- idx = 0
290
- st.selectbox(
291
- "Visibilitat",
292
- options,
293
- index=idx,
294
- key="video_visibility",
295
- disabled=disabled_vis,
296
- )
297
-
298
- if uploaded_file is not None:
299
- # Resetear el estado si se sube un nuevo archivo
300
- if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get(
301
- "original_name"
302
- ):
303
- st.session_state.video_uploaded = {"original_name": uploaded_file.name, "status": "validating"}
304
- st.session_state.characters_detected = None
305
- st.session_state.characters_saved = False
306
-
307
- if st.session_state.video_uploaded["status"] == "validating":
308
- is_valid = True
309
- if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
310
- st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
311
- is_valid = False
312
-
313
- if is_valid:
314
- with st.spinner("Processant el vídeo..."):
315
- temp_path = Path("temp_video.mp4")
316
- with temp_path.open("wb") as f:
317
- f.write(uploaded_file.getbuffer())
318
-
319
- was_truncated = False
320
- final_video_path = None
321
- try:
322
- duration = _get_video_duration(str(temp_path))
323
- duration_unknown = False
324
- if not duration:
325
- st.warning(
326
- "No s'ha pogut obtenir la durada del vídeo. Es continuarà assumint un màxim de 4 minuts."
327
- )
328
- duration = float(MAX_DURATION_S)
329
- duration_unknown = True
330
-
331
- if is_valid:
332
- if duration > MAX_DURATION_S:
333
- was_truncated = True
334
-
335
- video_name = Path(uploaded_file.name).stem
336
- video_dir = Path("/tmp/data/videos") / video_name
337
- video_dir.mkdir(parents=True, exist_ok=True)
338
- # Guardem sempre el vídeo original com a "video.mp4" dins la carpeta
339
- final_video_path = video_dir / "video.mp4"
340
-
341
- try:
342
- _transcode_video(
343
- str(temp_path),
344
- str(final_video_path),
345
- MAX_DURATION_S if (was_truncated or duration_unknown) else None,
346
- )
347
- except RuntimeError as exc:
348
- st.error(f"No s'ha pogut processar el vídeo: {exc}")
349
- is_valid = False
350
-
351
- if is_valid and final_video_path is not None:
352
- video_bytes = uploaded_file.getvalue()
353
- sha1 = hashlib.sha1(video_bytes).hexdigest()
354
-
355
- st.session_state.video_uploaded.update(
356
- {
357
- "status": "processed",
358
- "path": str(final_video_path),
359
- "was_truncated": was_truncated or duration_unknown,
360
- "duration_unknown": duration_unknown,
361
- "bytes": video_bytes,
362
- "name": uploaded_file.name,
363
- "sha1sum": sha1,
364
- }
365
- )
366
-
367
- # Registre d'esdeveniment de pujada de vídeo a events.db
368
- try:
369
- session_id = st.session_state.get("session_id", "")
370
- ip = st.session_state.get("client_ip", "")
371
- username = (
372
- (st.session_state.get("user") or {}).get("username")
373
- if st.session_state.get("user")
374
- else ""
375
- )
376
- password = st.session_state.get("last_password", "")
377
- phone = (
378
- st.session_state.get("sms_phone_verified")
379
- or st.session_state.get("sms_phone")
380
- or ""
381
- )
382
- vis_choice = st.session_state.get("video_visibility", "Privat")
383
- vis_flag = "public" if vis_choice.strip().lower().startswith("púb") else "private"
384
- log_event(
385
- session=session_id,
386
- ip=ip,
387
- user=username or "",
388
- password=password or "",
389
- phone=phone,
390
- action="upload",
391
- sha1sum=sha1,
392
- visibility=vis_flag,
393
- )
394
- except Exception as e:
395
- print(f"[events] Error registrant esdeveniment de pujada: {e}")
396
-
397
- # Si treballem en mode external, enviar el vídeo a pending_videos de l'engine
398
- try:
399
- base_dir = Path(__file__).parent.parent
400
- data_origin = _load_data_origin(base_dir)
401
- if data_origin == "external":
402
- pending_root = base_dir / "temp" / "pending_videos" / sha1
403
- pending_root.mkdir(parents=True, exist_ok=True)
404
- local_pending_path = pending_root / "video.mp4"
405
- # Guardar còpia local del vídeo pendent
406
- with local_pending_path.open("wb") as f_pending:
407
- f_pending.write(video_bytes)
408
-
409
- # Enviar el vídeo al backend engine perquè aparegui a la llista de pendents
410
- try:
411
- resp_pending = api.upload_pending_video(video_bytes, uploaded_file.name)
412
- _log(f"[pending_videos] upload_pending_video resp: {resp_pending}")
413
- except Exception as e_up:
414
- _log(f"[pending_videos] Error cridant upload_pending_video: {e_up}")
415
- except Exception as e_ext:
416
- _log(f"[pending_videos] Error bloc exterior upload_pending_video: {e_ext}")
417
-
418
- # Marcar estat de validació segons la configuració de seguretat
419
- if manual_validation_enabled:
420
- st.session_state.video_requires_validation = True
421
- st.session_state.video_validation_approved = False
422
- try:
423
- compliance_client.notify_video_upload(
424
- video_name=uploaded_file.name,
425
- sha1sum=sha1,
426
- )
427
- except Exception as sms_exc:
428
- print(f"[VIDEO SMS] Error enviant notificació al validor: {sms_exc}")
429
- else:
430
- # Sense validació manual: es considera validat automàticament
431
- st.session_state.video_requires_validation = False
432
- st.session_state.video_validation_approved = True
433
-
434
- st.rerun()
435
- finally:
436
- if temp_path.exists():
437
- temp_path.unlink()
438
-
439
- if st.session_state.video_uploaded and st.session_state.video_uploaded["status"] == "processed":
440
- st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
441
- if st.session_state.video_uploaded["was_truncated"]:
442
- st.warning("El vídeo s'ha truncat a 4 minuts.")
443
- if manual_validation_enabled and st.session_state.get("video_requires_validation") and not st.session_state.get("video_validation_approved"):
444
- st.info("Per favor, espera a la revisió humana del vídeo.")
445
-
446
- # Comprovar si hi ha aprovació de vídeo a events.db per al sha1sum actual
447
- current_sha1 = None
448
- if st.session_state.get("video_uploaded"):
449
- current_sha1 = st.session_state.video_uploaded.get("sha1sum")
450
- if current_sha1 and st.session_state.get("video_requires_validation") and not st.session_state.get("video_validation_approved"):
451
- if has_video_approval_event(current_sha1):
452
- st.session_state.video_validation_approved = True
453
-
454
- # Només podem continuar amb el càsting si el vídeo no requereix validació
455
- # o si ja ha estat marcat com a validat.
456
- can_proceed_casting = (
457
- st.session_state.get("video_uploaded") is not None
458
- and (
459
- not st.session_state.get("video_requires_validation")
460
- or st.session_state.get("video_validation_approved")
461
- )
462
- )
463
-
464
- # --- 2. Form de detecció amb sliders ---
465
- # Només es mostra quan ja hi ha un vídeo pujat **i** està validat (si cal validació).
466
- if can_proceed_casting:
467
- st.markdown("---")
468
-
469
- with st.form("detect_form"):
470
- col_btn, col_face, col_voice, col_scene = st.columns([1, 1, 1, 1])
471
- with col_face:
472
- st.markdown("**Cares**")
473
- face_max_groups = st.slider("Límit de grups (cares)", 1, 10, 5, 1, key="face_max_groups")
474
- face_min_cluster = st.slider("Mida mínima (cares)", 1, 5, 3, 1, key="face_min_cluster")
475
- face_sensitivity = st.slider("Sensibilitat (cares)", 0.0, 1.0, 0.5, 0.05, key="face_sensitivity",
476
- help="0.0 = menys clusters (més agressiu), 0.5 = balancejat, 1.0 = més clusters (més permissiu)")
477
- with col_voice:
478
- st.markdown("**Veus**")
479
- voice_max_groups = st.slider("Límit de grups (veus)", 1, 10, 5, 1, key="voice_max_groups")
480
- voice_min_cluster = st.slider("Mida mínima (veus)", 1, 5, 3, 1, key="voice_min_cluster")
481
- voice_sensitivity = st.slider("Sensibilitat (veus)", 0.0, 1.0, 0.5, 0.05, key="voice_sensitivity",
482
- help="0.0 = menys clusters (més agressiu), 0.5 = balancejat, 1.0 = més clusters (més permissiu)")
483
- with col_scene:
484
- st.markdown("**Escenes**")
485
- scene_max_groups = st.slider("Límit de grups (escenes)", 1, 10, 3, 1, key="scene_max_groups")
486
- scene_min_cluster = st.slider("Mida mínima (escenes)", 5, 20, 12, 1, key="scene_min_cluster")
487
- scene_sensitivity = st.slider("Sensibilitat (escenes)", 0.0, 1.0, 0.5, 0.05, key="scene_sensitivity",
488
- help="0.0 = menys clusters (més agressiu), 0.5 = balancejat, 1.0 = més clusters (més permissiu)")
489
- with col_btn:
490
- max_frames = st.number_input("Nombre de frames a processar", min_value=10, max_value=500, value=20, step=10,
491
- help="Nombre de fotogrames equiespaciats a extreure del vídeo per detectar cares")
492
- can_detect = True
493
- submit_detect = st.form_submit_button("Detectar Personatges", disabled=not can_detect)
494
-
495
- if not can_detect:
496
- st.caption("📹 Necessites pujar un vídeo primer")
497
-
498
- if submit_detect:
499
- import time as _t
500
- import os as _os
501
- msg_detect.empty()
502
- msg_finalize.empty()
503
- msg_ad.empty()
504
- try:
505
- v = st.session_state.video_uploaded
506
- # Reset estat abans de començar
507
- st.session_state.scene_clusters = None
508
- st.session_state.scene_detection_done = False
509
- st.session_state.detect_done = False
510
- st.session_state.casting_finalized = False
511
-
512
- resp = api.create_initial_casting(
513
- video_bytes=v["bytes"],
514
- video_name=v["name"],
515
- face_max_groups=face_max_groups,
516
- face_min_cluster_size=face_min_cluster,
517
- face_sensitivity=face_sensitivity,
518
- voice_max_groups=voice_max_groups,
519
- voice_min_cluster_size=voice_min_cluster,
520
- voice_sensitivity=voice_sensitivity,
521
- max_frames=max_frames,
522
- )
523
-
524
- if not isinstance(resp, dict) or not resp.get("job_id"):
525
- msg_detect.error("No s'ha pogut crear el job al servidor. Torna-ho a intentar.")
526
- else:
527
- job_id = resp["job_id"]
528
- msg_detect.info(f"Job creat: {job_id}. Iniciant polling en 3s…")
529
- with st.spinner("Processant al servidor…"):
530
- _t.sleep(3)
531
- attempt, max_attempts = 0, 120
532
- progress_placeholder = st.empty()
533
- while attempt < max_attempts:
534
- stt = api.get_job(job_id)
535
- status = stt.get("status")
536
- if status in ("queued", "processing"):
537
- if attempt % 10 == 0:
538
- elapsed_min = (attempt * 5) // 60
539
- progress_placeholder.info(f"⏳ Processant al servidor... (~{elapsed_min} min)")
540
- _t.sleep(5)
541
- attempt += 1
542
- continue
543
- if status == "failed":
544
- progress_placeholder.empty()
545
- msg_detect.error("El processament ha fallat al servidor.")
546
- break
547
-
548
- # Success
549
- res = stt.get("results", {})
550
- chars = res.get("characters", [])
551
- fl = res.get("face_labels", [])
552
- segs = res.get("audio_segments", [])
553
- vl = res.get("voice_labels", [])
554
- base_dir = res.get("base_dir")
555
- vname = _os.path.basename(base_dir) if base_dir else None
556
- diar_info = res.get("diarization_info", {})
557
-
558
- st.session_state.characters_detected = chars or []
559
- st.session_state.face_labels = fl or []
560
- st.session_state.audio_segments = segs or []
561
- st.session_state.voice_labels = vl or []
562
- st.session_state.video_name_from_engine = vname
563
- st.session_state.engine_base_dir = base_dir
564
- st.session_state.diarization_info = diar_info or {}
565
-
566
- progress_placeholder.empty()
567
-
568
- if chars:
569
- msg_detect.success(
570
- f"✓ Detecció completada! Trobades {len(chars)} cares.\n\n"
571
- "💡 Usa els botons '🎨 Generar descripció' a sota de cada personatge per obtenir descripcions automàtiques amb Salamandra Vision."
572
- )
573
- else:
574
- msg_detect.info("No s'han detectat cares en aquest vídeo.")
575
-
576
- # Detect scenes
577
- try:
578
- scene_out = api.detect_scenes(
579
- video_bytes=v["bytes"],
580
- video_name=v["name"],
581
- max_groups=scene_max_groups,
582
- min_cluster_size=scene_min_cluster,
583
- scene_sensitivity=scene_sensitivity,
584
- frame_interval_sec=0.5,
585
- )
586
- scs = scene_out.get("scene_clusters") if isinstance(scene_out, dict) else None
587
- if isinstance(scs, list):
588
- st.session_state.scene_clusters = scs
589
- else:
590
- st.session_state.scene_clusters = []
591
- except Exception:
592
- st.session_state.scene_clusters = []
593
- finally:
594
- st.session_state.scene_detection_done = True
595
-
596
- st.session_state.detect_done = True
597
- msg_detect.success("✅ Processament completat!")
598
- break
599
- else:
600
- progress_placeholder.empty()
601
- msg_detect.warning(f"⏱️ El servidor no ha completat el job en {max_attempts * 5 // 60} minuts.")
602
- except Exception as e:
603
- msg_detect.error(f"Error inesperat: {e}")
604
-
605
- # Botó per actualitzar manualment l'estat de validació del vídeo
606
- if st.session_state.get("video_uploaded"):
607
- col_status, col_refresh = st.columns([3, 1])
608
- with col_status:
609
- if st.session_state.get("video_requires_validation") and not st.session_state.get("video_validation_approved"):
610
- st.caption("⏳ Vídeo pendent de validació humana.")
611
- elif st.session_state.get("video_validation_approved"):
612
- st.caption("✅ Vídeo validat. Pots generar el càsting.")
613
- with col_refresh:
614
- if st.button("🔄 Actualitzar estat de validació", key="refresh_video_validation"):
615
- # Re-sincronitzar BDs temp (inclosa events.db) des de l'origen
616
- try:
617
- base_dir = Path(__file__).parent.parent
618
- api_client = st.session_state.get("api_client")
619
- ensure_temp_databases(base_dir, api_client)
620
- except Exception:
621
- pass
622
-
623
- if current_sha1:
624
- if has_video_approval_event(current_sha1):
625
- st.session_state.video_validation_approved = True
626
- st.success("✅ Vídeo validat. Pots continuar amb el càsting.")
627
- else:
628
- st.info("Encara no s'ha registrat cap aprovació per a aquest vídeo.")
629
-
630
- # --- 3. Carruseles de cares ---
631
- if st.session_state.get("characters_detected") is not None:
632
- st.markdown("---")
633
- n_face_clusters = len(st.session_state.get("characters_detected") or [])
634
- st.subheader(f"🖼️ Cares — clústers: {n_face_clusters}")
635
-
636
- if n_face_clusters == 0:
637
- st.info("No s'han detectat clústers de cara en aquest clip.")
638
-
639
- for idx, ch in enumerate(st.session_state.characters_detected or []):
640
- try:
641
- folder_name = Path(ch.get("folder") or "").name
642
- except Exception:
643
- folder_name = ""
644
- char_id = ch.get("id") or folder_name or f"char{idx+1}"
645
-
646
- def _safe_key(s: str) -> str:
647
- k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
648
- return k or f"cluster_{idx+1}"
649
-
650
- key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
651
- if f"{key_prefix}_idx" not in st.session_state:
652
- st.session_state[f"{key_prefix}_idx"] = 0
653
- if f"{key_prefix}_discard" not in st.session_state:
654
- st.session_state[f"{key_prefix}_discard"] = set()
655
-
656
- faces_all = ch.get("face_files") or ([ch.get("image_url")] if ch.get("image_url") else [])
657
- faces_all = [f for f in faces_all if f]
658
- discard_set = st.session_state[f"{key_prefix}_discard"]
659
- faces = [f for f in faces_all if f not in discard_set]
660
-
661
- if not faces:
662
- st.write(f"- {idx+1}. {ch.get('name','(sense nom)')} — sense imatges seleccionades")
663
- continue
664
-
665
- cur = st.session_state[f"{key_prefix}_idx"]
666
- if cur >= len(faces):
667
- cur = 0
668
- st.session_state[f"{key_prefix}_idx"] = cur
669
- fname = faces[cur]
670
-
671
- if fname.startswith("/files/"):
672
- img_url = f"{backend_base_url}{fname}"
673
- else:
674
- base = ch.get("image_url") or ""
675
- base_dir = "/".join((base or "/").split("/")[:-1])
676
- img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
677
-
678
- st.markdown(f"**{idx+1}. {ch.get('name','(sense nom)')} — {ch.get('num_faces', 0)} cares**")
679
- spacer_col, main_content_col = st.columns([0.12, 0.88])
680
- with spacer_col:
681
- st.write("")
682
- with main_content_col:
683
- media_col, form_col = st.columns([1.3, 2.7])
684
- with media_col:
685
- st.image(img_url, width=180)
686
- st.caption(f"Imatge {cur+1}/{len(faces)}")
687
- nav_prev, nav_del, nav_next = st.columns(3)
688
- with nav_prev:
689
- if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
690
- st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(faces)
691
- st.rerun()
692
- with nav_del:
693
- if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquesta imatge del clúster"):
694
- st.session_state[f"{key_prefix}_discard"].add(fname)
695
- new_list = [f for f in faces if f != fname]
696
- new_idx = cur if cur < len(new_list) else 0
697
- st.session_state[f"{key_prefix}_idx"] = new_idx
698
- st.rerun()
699
- with nav_next:
700
- if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
701
- st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(faces)
702
- st.rerun()
703
- name_key = f"{key_prefix}_name"
704
- desc_key = f"{key_prefix}_desc"
705
- default_name = ch.get("name", "")
706
- default_desc = ch.get("description", "")
707
-
708
- if default_name and (name_key not in st.session_state or not st.session_state.get(name_key)):
709
- st.session_state[name_key] = default_name
710
- elif name_key not in st.session_state:
711
- st.session_state[name_key] = default_name or ""
712
-
713
- if default_desc and (desc_key not in st.session_state or not st.session_state.get(desc_key)):
714
- st.session_state[desc_key] = default_desc
715
- elif desc_key not in st.session_state:
716
- st.session_state[desc_key] = default_desc or ""
717
-
718
- pending_desc_key = f"{key_prefix}_pending_desc"
719
- pending_name_key = f"{key_prefix}_pending_name"
720
- if pending_desc_key in st.session_state:
721
- if desc_key not in st.session_state:
722
- st.session_state[desc_key] = ""
723
- st.session_state[desc_key] = st.session_state[pending_desc_key]
724
- del st.session_state[pending_desc_key]
725
-
726
- if pending_name_key in st.session_state:
727
- if name_key not in st.session_state:
728
- st.session_state[name_key] = ""
729
- if not st.session_state.get(name_key):
730
- st.session_state[name_key] = st.session_state[pending_name_key]
731
- del st.session_state[pending_name_key]
732
-
733
- with form_col:
734
- st.text_input("Nom del clúster", key=name_key)
735
- st.text_area("Descripció", key=desc_key, height=80)
736
-
737
- if st.button("🎨 Generar descripció amb Salamandra Vision", key=f"svision_{key_prefix}"):
738
- with st.spinner("Generant descripció..."):
739
- from api_client import describe_image_with_svision
740
- import requests as _req
741
- import os as _os
742
- import tempfile
743
-
744
- try:
745
- resp = _req.get(img_url, timeout=10)
746
- if resp.status_code == 200:
747
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
748
- tmp.write(resp.content)
749
- tmp_path = tmp.name
750
-
751
- try:
752
- desc, name = describe_image_with_svision(tmp_path, is_face=True)
753
-
754
- if desc:
755
- st.session_state[pending_desc_key] = desc
756
- st.success("✅ Descripció generada!")
757
- print(f"[SVISION] Descripció generada per {char_id}: {desc[:100]}")
758
- else:
759
- st.warning("⚠️ No s'ha pogut generar una descripció.")
760
- print(f"[SVISION] Descripció buida per {char_id}")
761
-
762
- if name and not st.session_state.get(name_key):
763
- st.session_state[pending_name_key] = name
764
- print(f"[SVISION] Nom generat per {char_id}: {name}")
765
-
766
- finally:
767
- # Always clean up the temp file
768
- try:
769
- _os.unlink(tmp_path)
770
- except Exception as cleanup_err:
771
- print(f"[SVISION] Error netejant fitxer temporal: {cleanup_err}")
772
-
773
- st.rerun()
774
- else:
775
- st.error(f"No s'ha pogut descarregar la imatge (status: {resp.status_code})")
776
-
777
- except Exception as e:
778
- st.error(f"Error generant descripció: {str(e)}")
779
- print(f"[SVISION] Error complet: {e}")
780
- import traceback
781
- traceback.print_exc()
782
-
783
- # --- 4. Carruseles de veus ---
784
- if st.session_state.get("audio_segments") is not None:
785
- st.markdown("---")
786
-
787
- used_names_home = []
788
- used_names_dona = []
789
- noms_home_all, noms_dona_all = get_all_catalan_names()
790
-
791
- for ch in (st.session_state.characters_detected or []):
792
- ch_name = ch.get("name", "")
793
- if ch_name in noms_home_all:
794
- used_names_home.append(ch_name)
795
- elif ch_name in noms_dona_all:
796
- used_names_dona.append(ch_name)
797
-
798
- segs = st.session_state.audio_segments or []
799
- vlabels = st.session_state.voice_labels or []
800
- valid_indices = [i for i, l in enumerate(vlabels) if isinstance(l, int) and l >= 0]
801
- clusters = {}
802
- for i in valid_indices:
803
- lbl = int(vlabels[i])
804
- clusters.setdefault(lbl, []).append(i)
805
- n_vclusters = len(clusters)
806
- st.subheader(f"🎙️ Empremtes de veu — clústers: {n_vclusters}")
807
- di = st.session_state.get("diarization_info") or {}
808
- if isinstance(di, dict) and not di.get("diarization_ok", True):
809
- st.warning("No s'ha pogut fer la diarització amb pyannote (s'ha aplicat un sol segment de reserva).")
810
- if not segs:
811
- st.info("No s'han detectat mostres de veu.")
812
- elif n_vclusters == 0:
813
- st.info("No s'han format clústers de veu.")
814
- else:
815
- vname = st.session_state.video_name_from_engine
816
- for lbl, idxs in sorted(clusters.items(), key=lambda x: x[0]):
817
- key_prefix = f"voice_{lbl:02d}"
818
- if f"{key_prefix}_idx" not in st.session_state:
819
- st.session_state[f"{key_prefix}_idx"] = 0
820
- if f"{key_prefix}_discard" not in st.session_state:
821
- st.session_state[f"{key_prefix}_discard"] = set()
822
- discard_set = st.session_state[f"{key_prefix}_discard"]
823
- files = []
824
- for i in idxs:
825
- clip_local = (segs[i] or {}).get("clip_path")
826
- fname = os.path.basename(clip_local) if clip_local else None
827
- if fname:
828
- files.append(fname)
829
- files = [f for f in files if f and f not in discard_set]
830
- if not files:
831
- st.write(f"- SPEAKER_{lbl:02d} — sense clips seleccionats")
832
- continue
833
- cur = st.session_state[f"{key_prefix}_idx"]
834
- if cur >= len(files):
835
- cur = 0
836
- st.session_state[f"{key_prefix}_idx"] = cur
837
- fname = files[cur]
838
- audio_url = f"{backend_base_url}/audio/{vname}/{fname}" if (vname and fname) else None
839
- st.markdown(f"**SPEAKER_{lbl:02d} — {len(files)} clips**")
840
- c1, c2 = st.columns([1, 2])
841
- with c1:
842
- if audio_url:
843
- st.audio(audio_url, format="audio/wav")
844
- st.caption(f"Clip {cur+1}/{len(files)}")
845
- bcol1, bcol2, bcol3 = st.columns(3)
846
- with bcol1:
847
- if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
848
- st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(files)
849
- st.rerun()
850
- with bcol2:
851
- if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquest clip del clúster"):
852
- st.session_state[f"{key_prefix}_discard"].add(fname)
853
- new_list = [f for f in files if f != fname]
854
- new_idx = cur if cur < len(new_list) else 0
855
- st.session_state[f"{key_prefix}_idx"] = new_idx
856
- st.rerun()
857
- with bcol3:
858
- if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
859
- st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(files)
860
- st.rerun()
861
- with c2:
862
- name_key = f"{key_prefix}_name"
863
- desc_key = f"{key_prefix}_desc"
864
- default_name = get_catalan_name_for_speaker(lbl, used_names_home, used_names_dona)
865
- st.text_input("Nom del clúster", value=st.session_state.get(name_key, default_name), key=name_key)
866
- st.text_area("Descripció", value=st.session_state.get(desc_key, ""), key=desc_key, height=80)
867
-
868
-
869
- # --- 5. Carruseles de escenas ---
870
- if st.session_state.get("scene_detection_done"):
871
- st.markdown("---")
872
- scene_clusters = st.session_state.get("scene_clusters")
873
- n_scenes = len(scene_clusters or [])
874
- st.subheader(f"📍 Escenes — clústers: {n_scenes}")
875
- if not scene_clusters:
876
- st.info("No s'han detectat clústers d'escenes en aquest clip.")
877
- else:
878
- for sidx, sc in enumerate(scene_clusters):
879
- try:
880
- folder_name = Path(sc.get("folder") or "").name
881
- except Exception:
882
- folder_name = ""
883
- scene_id = sc.get("id") or folder_name or f"scene{sidx+1}"
884
- key_prefix = re.sub(r"[^0-9a-zA-Z_]+", "_", f"scene_{sidx+1}_{scene_id}") or f"scene_{sidx+1}"
885
- if f"{key_prefix}_idx" not in st.session_state:
886
- st.session_state[f"{key_prefix}_idx"] = 0
887
- if f"{key_prefix}_discard" not in st.session_state:
888
- st.session_state[f"{key_prefix}_discard"] = set()
889
- frames_all = sc.get("frame_files") or ([sc.get("image_url")] if sc.get("image_url") else [])
890
- frames_all = [f for f in frames_all if f]
891
- discard_set = st.session_state[f"{key_prefix}_discard"]
892
- frames = [f for f in frames_all if f not in discard_set]
893
- if not frames:
894
- st.write(f"- {sidx+1}. (sense imatges de l'escena)")
895
- continue
896
- cur = st.session_state[f"{key_prefix}_idx"]
897
- if cur >= len(frames):
898
- cur = 0
899
- st.session_state[f"{key_prefix}_idx"] = cur
900
- fname = frames[cur]
901
- if str(fname).startswith("/files/"):
902
- img_url = f"{backend_base_url}{fname}"
903
- else:
904
- base = sc.get("image_url") or ""
905
- base_dir = "/".join((base or "/").split("/")[:-1])
906
- img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
907
- st.markdown(f"**{sidx+1}. Escena — {sc.get('num_frames', 0)} frames**")
908
- spacer_col, main_content_col = st.columns([0.12, 0.88])
909
- with spacer_col:
910
- st.write("")
911
- with main_content_col:
912
- media_col, form_col = st.columns([1.4, 2.6])
913
- with media_col:
914
- st.image(img_url, width=220)
915
- st.caption(f"Imatge {cur+1}/{len(frames)}")
916
- nav_prev, nav_del, nav_next = st.columns(3)
917
- with nav_prev:
918
- if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
919
- st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(frames)
920
- st.rerun()
921
- with nav_del:
922
- if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquesta imatge del clúster"):
923
- st.session_state[f"{key_prefix}_discard"].add(fname)
924
- new_list = [f for f in frames if f != fname]
925
- new_idx = cur if cur < len(new_list) else 0
926
- st.session_state[f"{key_prefix}_idx"] = new_idx
927
- st.rerun()
928
- with nav_next:
929
- if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
930
- st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(frames)
931
- st.rerun()
932
- name_key = f"{key_prefix}_name"
933
- desc_key = f"{key_prefix}_desc"
934
- default_scene_name = sc.get("name", "")
935
- default_scene_desc = sc.get("description", "")
936
-
937
- if default_scene_name and (name_key not in st.session_state or not st.session_state.get(name_key)):
938
- st.session_state[name_key] = default_scene_name
939
- elif name_key not in st.session_state:
940
- st.session_state[name_key] = default_scene_name or ""
941
-
942
- if default_scene_desc and (desc_key not in st.session_state or not st.session_state.get(desc_key)):
943
- st.session_state[desc_key] = default_scene_desc
944
- elif desc_key not in st.session_state:
945
- st.session_state[desc_key] = default_scene_desc or ""
946
-
947
- pending_desc_key = f"{key_prefix}_pending_desc"
948
- pending_name_key = f"{key_prefix}_pending_name"
949
- if pending_desc_key in st.session_state:
950
- if desc_key not in st.session_state:
951
- st.session_state[desc_key] = ""
952
- st.session_state[desc_key] = st.session_state[pending_desc_key]
953
- del st.session_state[pending_desc_key]
954
-
955
- if pending_name_key in st.session_state:
956
- if name_key not in st.session_state:
957
- st.session_state[name_key] = ""
958
- if not st.session_state.get(name_key):
959
- st.session_state[name_key] = st.session_state[pending_name_key]
960
- del st.session_state[pending_name_key]
961
-
962
- with form_col:
963
- st.text_input("Nom del clúster", key=name_key)
964
- st.text_area("Descripció", key=desc_key, height=80)
965
-
966
- if st.button("🎨 Generar descripció amb Salamandra Vision", key=f"svision_{key_prefix}"):
967
- with st.spinner("Generant descripció..."):
968
- from api_client import describe_image_with_svision, generate_short_scene_name
969
- import requests as _req
970
- import os as _os
971
- import tempfile
972
-
973
- try:
974
- resp = _req.get(img_url, timeout=10)
975
- if resp.status_code == 200:
976
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
977
- tmp.write(resp.content)
978
- tmp_path = tmp.name
979
-
980
- try:
981
- desc, name = describe_image_with_svision(tmp_path, is_face=False)
982
-
983
- if desc:
984
- st.session_state[pending_desc_key] = desc
985
- print(f"[SVISION] Descripció d'escena generada per {scene_id}: {desc[:100]}")
986
-
987
- try:
988
- short_name = generate_short_scene_name(desc)
989
- if short_name:
990
- st.session_state[pending_name_key] = short_name
991
- print(f"[SCHAT] Nom curt generat: {short_name}")
992
- elif name:
993
- st.session_state[pending_name_key] = name
994
- print(f"[SVISION] Usant nom original: {name}")
995
- except Exception as schat_err:
996
- print(f"[SCHAT] Error: {schat_err}")
997
- if name:
998
- st.session_state[pending_name_key] = name
999
- print(f"[SVISION] Usant nom original fallback: {name}")
1000
-
1001
- st.success("✅ Descripció i nom generats!")
1002
- else:
1003
- st.warning("⚠️ No s'ha pogut generar una descripció.")
1004
- print(f"[SVISION] Descripció d'escena buida per {scene_id}")
1005
-
1006
- finally:
1007
- # Always clean up the temp file
1008
- try:
1009
- _os.unlink(tmp_path)
1010
- except Exception as cleanup_err:
1011
- print(f"[SVISION] Error netejant fitxer temporal: {cleanup_err}")
1012
-
1013
- st.rerun()
1014
- else:
1015
- st.error(f"No s'ha pogut descarregar la imatge (status: {resp.status_code})")
1016
-
1017
- except Exception as e:
1018
- st.error(f"Error generant descripció: {str(e)}")
1019
- print(f"[SVISION] Error complet: {e}")
1020
- import traceback
1021
- traceback.print_exc()
1022
-
1023
- # --- 6. Confirmación de casting y personajes combinados ---
1024
- if st.session_state.get("detect_done"):
1025
- st.markdown("---")
1026
- colc1, colc2 = st.columns([1,1])
1027
- with colc1:
1028
- if st.button("Confirmar càsting definitiu", type="primary"):
1029
- chars_payload = []
1030
- for idx, ch in enumerate(st.session_state.characters_detected or []):
1031
- try:
1032
- folder_name = Path(ch.get("folder") or "").name
1033
- except Exception:
1034
- folder_name = ""
1035
- char_id = ch.get("id") or folder_name or f"char{idx+1}"
1036
- def _safe_key(s: str) -> str:
1037
- k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
1038
- return k or f"cluster_{idx+1}"
1039
- key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
1040
- name = st.session_state.get(f"{key_prefix}_name") or ch.get("name") or f"Personatge {idx+1}"
1041
- desc = st.session_state.get(f"{key_prefix}_desc", "")
1042
- faces_all = ch.get("face_files") or []
1043
- discard = st.session_state.get(f"{key_prefix}_discard", set())
1044
- kept = [f for f in faces_all if f and f not in discard]
1045
- chars_payload.append({
1046
- "id": char_id,
1047
- "name": name,
1048
- "description": desc,
1049
- "folder": ch.get("folder"),
1050
- "kept_files": kept,
1051
- })
1052
-
1053
- used_names_home_fin = []
1054
- used_names_dona_fin = []
1055
- noms_home_all, noms_dona_all = get_all_catalan_names()
1056
- for cp in chars_payload:
1057
- face_name = cp.get("name", "")
1058
- if face_name in noms_home_all:
1059
- used_names_home_fin.append(face_name)
1060
- elif face_name in noms_dona_all:
1061
- used_names_dona_fin.append(face_name)
1062
-
1063
- segs = st.session_state.audio_segments or []
1064
- vlabels = st.session_state.voice_labels or []
1065
- vname = st.session_state.video_name_from_engine
1066
- voice_clusters = {}
1067
- for i, seg in enumerate(segs):
1068
- lbl = vlabels[i] if i < len(vlabels) else -1
1069
- # Només considerem clústers de veu amb etiqueta vàlida (enter >= 0)
1070
- if not (isinstance(lbl, int) and lbl >= 0):
1071
- continue
1072
- clip_local = seg.get("clip_path")
1073
- fname = os.path.basename(clip_local) if clip_local else None
1074
- if fname:
1075
- default_voice_name = get_catalan_name_for_speaker(int(lbl), used_names_home_fin, used_names_dona_fin)
1076
- voice_clusters.setdefault(lbl, {"label": lbl, "name": default_voice_name, "description": "", "clips": []})
1077
- vpref = f"voice_{int(lbl):02d}"
1078
- vname_custom = st.session_state.get(f"{vpref}_name")
1079
- vdesc_custom = st.session_state.get(f"{vpref}_desc")
1080
- if vname_custom:
1081
- voice_clusters[lbl]["name"] = vname_custom
1082
- if vdesc_custom is not None:
1083
- voice_clusters[lbl]["description"] = vdesc_custom
1084
- voice_clusters[lbl]["clips"].append(fname)
1085
-
1086
- payload = {
1087
- "video_name": vname,
1088
- "base_dir": st.session_state.get("engine_base_dir"),
1089
- "characters": chars_payload,
1090
- "voice_clusters": list(voice_clusters.values()),
1091
- }
1092
-
1093
- if not payload["video_name"] or not payload["base_dir"]:
1094
- st.error("Falten dades del vídeo per confirmar el càsting (video_name/base_dir). Torna a processar el vídeo.")
1095
- else:
1096
- with st.spinner("Consolidant càsting al servidor…"):
1097
- res_fc = api.finalize_casting(payload)
1098
- if isinstance(res_fc, dict) and res_fc.get("ok"):
1099
- st.success(f"Càsting consolidat. Identities: {len(res_fc.get('face_identities', []))} cares, {len(res_fc.get('voice_identities', []))} veus.")
1100
- st.session_state.casting_finalized = True
1101
-
1102
- f_id = res_fc.get('face_identities', []) or []
1103
- v_id = res_fc.get('voice_identities', []) or []
1104
- c3, c4 = st.columns(2)
1105
- with c3:
1106
- st.markdown("**Identitats de cara**")
1107
- for n in f_id:
1108
- st.write(f"- {n}")
1109
- with c4:
1110
- st.markdown("**Identitats de veu**")
1111
- for n in v_id:
1112
- st.write(f"- {n}")
1113
-
1114
- faces_dir = res_fc.get('faces_dir')
1115
- voices_dir = res_fc.get('voices_dir')
1116
- db_dir = res_fc.get('db_dir')
1117
- with st.spinner("Carregant índexs al cercador (Chroma)…"):
1118
- load_res = api.load_casting(faces_dir=faces_dir, voices_dir=voices_dir, db_dir=db_dir, drop_collections=True)
1119
- if isinstance(load_res, dict) and load_res.get('ok'):
1120
- st.success(f"Índexs carregats: {load_res.get('faces', 0)} cares, {load_res.get('voices', 0)} veus.")
1121
- else:
1122
- st.error(f"Error carregant índexs: {load_res}")
1123
- else:
1124
- st.error(f"No s'ha pogut consolidar el càsting: {res_fc}")
1125
-
1126
- # --- Personatges combinats (cares + veus) ---
1127
- if st.session_state.get("casting_finalized"):
1128
- st.markdown("---")
1129
- st.subheader("👥 Personatges")
1130
-
1131
- def normalize_name(name: str) -> str:
1132
- import unicodedata
1133
- name_upper = name.upper()
1134
- name_normalized = ''.join(
1135
- c for c in unicodedata.normalize('NFD', name_upper)
1136
- if unicodedata.category(c) != 'Mn'
1137
- )
1138
- return name_normalized
1139
-
1140
- chars_payload = []
1141
- for idx, ch in enumerate(st.session_state.characters_detected or []):
1142
- try:
1143
- folder_name = Path(ch.get("folder") or "").name
1144
- except Exception:
1145
- folder_name = ""
1146
- char_id = ch.get("id") or folder_name or f"char{idx+1}"
1147
- def _safe_key(s: str) -> str:
1148
- k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
1149
- return k or f"cluster_{idx+1}"
1150
- key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
1151
- name = st.session_state.get(f"{key_prefix}_name") or ch.get("name") or f"Personatge {idx+1}"
1152
- name_normalized = normalize_name(name)
1153
- desc = st.session_state.get(f"{key_prefix}_desc", "").strip()
1154
- chars_payload.append({
1155
- "name": name,
1156
- "name_normalized": name_normalized,
1157
- "face_key_prefix": key_prefix,
1158
- "face_files": ch.get("face_files") or [],
1159
- "char_data": ch,
1160
- "description": desc,
1161
- })
1162
-
1163
- used_names_home_pers = []
1164
- used_names_dona_pers = []
1165
- noms_home_all, noms_dona_all = get_all_catalan_names()
1166
- for cp in chars_payload:
1167
- face_name = cp.get("name", "")
1168
- if face_name in noms_home_all:
1169
- used_names_home_pers.append(face_name)
1170
- elif face_name in noms_dona_all:
1171
- used_names_dona_pers.append(face_name)
1172
-
1173
- segs = st.session_state.audio_segments or []
1174
- vlabels = st.session_state.voice_labels or []
1175
- vname = st.session_state.video_name_from_engine
1176
- voice_clusters_by_name = {}
1177
- for i, seg in enumerate(segs):
1178
- lbl = vlabels[i] if i < len(vlabels) else -1
1179
- if not (isinstance(lbl, int) and lbl >= 0):
1180
- continue
1181
- vpref = f"voice_{int(lbl):02d}"
1182
- default_voice_name = get_catalan_name_for_speaker(int(lbl), used_names_home_pers, used_names_dona_pers) if isinstance(lbl, int) and lbl >= 0 else f"SPEAKER_{int(lbl):02d}"
1183
- vname_custom = st.session_state.get(f"{vpref}_name") or default_voice_name
1184
- vname_normalized = normalize_name(vname_custom)
1185
- vdesc = st.session_state.get(f"{vpref}_desc", "").strip()
1186
- clip_local = seg.get("clip_path")
1187
- fname = os.path.basename(clip_local) if clip_local else None
1188
- if fname:
1189
- voice_clusters_by_name.setdefault(vname_normalized, {
1190
- "voice_key_prefix": vpref,
1191
- "clips": [],
1192
- "label": lbl,
1193
- "original_name": vname_custom,
1194
- "description": vdesc,
1195
- })
1196
- voice_clusters_by_name[vname_normalized]["clips"].append(fname)
1197
-
1198
- all_normalized_names = set([c["name_normalized"] for c in chars_payload] + list(voice_clusters_by_name.keys()))
1199
-
1200
- for pidx, norm_name in enumerate(sorted(all_normalized_names)):
1201
- face_items = [c for c in chars_payload if c["name_normalized"] == norm_name]
1202
- voice_data = voice_clusters_by_name.get(norm_name)
1203
-
1204
- display_name = face_items[0]["name"] if face_items else (voice_data["original_name"] if voice_data else norm_name)
1205
-
1206
- descriptions = []
1207
- for face_item in face_items:
1208
- if face_item["description"]:
1209
- descriptions.append(face_item["description"])
1210
- if voice_data and voice_data.get("description"):
1211
- descriptions.append(voice_data["description"])
1212
-
1213
- combined_description = "\n".join(descriptions) if descriptions else ""
1214
-
1215
- st.markdown(f"**{pidx+1}. {display_name}**")
1216
-
1217
- all_faces = []
1218
- for face_item in face_items:
1219
- all_faces.extend(face_item["face_files"])
1220
-
1221
- face_data = face_items[0] if face_items else None
1222
-
1223
- col_faces, col_voices, col_text = st.columns([1, 1, 1.5])
1224
-
1225
- with col_faces:
1226
- if all_faces:
1227
- carousel_key = f"combined_face_{pidx}"
1228
- if f"{carousel_key}_idx" not in st.session_state:
1229
- st.session_state[f"{carousel_key}_idx"] = 0
1230
- cur = st.session_state[f"{carousel_key}_idx"]
1231
- if cur >= len(all_faces):
1232
- cur = 0
1233
- st.session_state[f"{carousel_key}_idx"] = cur
1234
- fname = all_faces[cur]
1235
- ch = face_data["char_data"] if face_data else {}
1236
- if fname.startswith("/files/"):
1237
- img_url = f"{backend_base_url}{fname}"
1238
- else:
1239
- base = ch.get("image_url") or ""
1240
- base_dir = "/".join((base or "/").split("/")[:-1])
1241
- img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
1242
- st.image(img_url, width=150)
1243
- st.caption(f"Cara {cur+1}/{len(all_faces)}")
1244
- bcol1, bcol2 = st.columns(2)
1245
- with bcol1:
1246
- if st.button("⬅️", key=f"combined_face_prev_{pidx}"):
1247
- st.session_state[f"{carousel_key}_idx"] = (cur - 1) % len(all_faces)
1248
- st.rerun()
1249
- with bcol2:
1250
- if st.button("➡️", key=f"combined_face_next_{pidx}"):
1251
- st.session_state[f"{carousel_key}_idx"] = (cur + 1) % len(all_faces)
1252
- st.rerun()
1253
- else:
1254
- st.info("Sense imatges")
1255
-
1256
- with col_voices:
1257
- if voice_data:
1258
- clips = voice_data["clips"]
1259
- if clips:
1260
- carousel_key = f"combined_voice_{pidx}"
1261
- if f"{carousel_key}_idx" not in st.session_state:
1262
- st.session_state[f"{carousel_key}_idx"] = 0
1263
- cur = st.session_state[f"{carousel_key}_idx"]
1264
- if cur >= len(clips):
1265
- cur = 0
1266
- st.session_state[f"{carousel_key}_idx"] = cur
1267
- fname = clips[cur]
1268
- audio_url = f"{backend_base_url}/audio/{vname}/{fname}" if (vname and fname) else None
1269
- if audio_url:
1270
- st.audio(audio_url, format="audio/wav")
1271
- st.caption(f"Veu {cur+1}/{len(clips)}")
1272
- bcol1, bcol2 = st.columns(2)
1273
- with bcol1:
1274
- if st.button("⬅️", key=f"combined_voice_prev_{pidx}"):
1275
- st.session_state[f"{carousel_key}_idx"] = (cur - 1) % len(clips)
1276
- st.rerun()
1277
- with bcol2:
1278
- if st.button("➡️", key=f"combined_voice_next_{pidx}"):
1279
- st.session_state[f"{carousel_key}_idx"] = (cur + 1) % len(clips)
1280
- st.rerun()
1281
- else:
1282
- st.info("Sense clips de veu")
1283
- else:
1284
- st.info("Sense dades de veu")
1285
-
1286
- with col_text:
1287
- combined_name_key = f"combined_char_{pidx}_name"
1288
- combined_desc_key = f"combined_char_{pidx}_desc"
1289
-
1290
- if combined_name_key not in st.session_state:
1291
- st.session_state[combined_name_key] = norm_name
1292
- if combined_desc_key not in st.session_state:
1293
- st.session_state[combined_desc_key] = combined_description
1294
-
1295
- st.text_input("Nom del personatge", key=combined_name_key, label_visibility="collapsed", placeholder="Nom del personatge")
1296
- st.text_area("Descripció", key=combined_desc_key, height=120, label_visibility="collapsed", placeholder="Descripció del personatge")
1297
-
1298
- # --- 7. Generar audiodescripció ---
1299
- st.markdown("---")
1300
- if st.button("🎬 Generar audiodescripció", type="primary", use_container_width=True):
1301
- v = st.session_state.get("video_uploaded")
1302
- if not v:
1303
- st.error("No hi ha cap vídeo carregat.")
1304
- else:
1305
- progress_placeholder = st.empty()
1306
- result_placeholder = st.empty()
1307
-
1308
- with st.spinner("Generant audiodescripció... Aquest procés pot trigar diversos minuts."):
1309
- progress_placeholder.info("⏳ Processant vídeo i generant audiodescripció UNE-153010...")
1310
-
1311
- try:
1312
- out = api.generate_audiodescription(v["bytes"], v["name"])
1313
-
1314
- if isinstance(out, dict) and out.get("status") == "done":
1315
- progress_placeholder.success("✅ Audiodescripció generada correctament!")
1316
- res = out.get("results", {})
1317
-
1318
- with result_placeholder.container():
1319
- st.success("🎉 Audiodescripció completada!")
1320
- c1, c2 = st.columns([1,1])
1321
- with c1:
1322
- st.markdown("**📄 UNE-153010 SRT**")
1323
- une_srt_content = res.get("une_srt", "")
1324
- st.code(une_srt_content, language="text")
1325
- if une_srt_content:
1326
- st.download_button(
1327
- "⬇️ Descarregar UNE SRT",
1328
- data=une_srt_content,
1329
- file_name=f"{v['name']}_une.srt",
1330
- mime="text/plain"
1331
- )
1332
- with c2:
1333
- st.markdown("**📝 Narració lliure**")
1334
- free_text_content = res.get("free_text", "")
1335
- st.text_area("", value=free_text_content, height=240, key="free_text_result")
1336
- if free_text_content:
1337
- st.download_button(
1338
- "⬇️ Descarregar text lliure",
1339
- data=free_text_content,
1340
- file_name=f"{v['name']}_free.txt",
1341
- mime="text/plain"
1342
- )
1343
- else:
1344
- progress_placeholder.empty()
1345
- error_msg = str(out.get("error", out)) if isinstance(out, dict) else str(out)
1346
- result_placeholder.error(f"❌ Error generant l'audiodescripció: {error_msg}")
1347
-
1348
- except Exception as e:
1349
- progress_placeholder.empty()
1350
- result_placeholder.error(f"❌ Excepció durant la generació: {e}")
 
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 CHANGED
@@ -1,100 +1,46 @@
1
- """UI logic for the "Estadístiques" page."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pandas as pd
8
- import streamlit as st
9
- import yaml
10
-
11
- from databases import get_feedback_video_stats
12
-
13
-
14
- def render_statistics_page() -> None:
15
- st.header("Estadístiques")
16
-
17
- col1, col2 = st.columns(2)
18
-
19
- with col1:
20
- mode_label = st.selectbox(
21
- "Mode d'agregació",
22
- ["mitjana", "mediana", "inicial", "actual"],
23
- help=(
24
- "mitjana: mitjana de totes les valoracions; "
25
- "mediana: valor central; "
26
- "inicial: primer registre en el temps; "
27
- "actual: darrer registre en el temps."
28
- ),
29
- )
30
-
31
- # Etiquetes humanes per als sis ítems (a partir de config.yaml -> labels)
32
- cfg_path = Path(__file__).resolve().parent.parent / "config.yaml"
33
- try:
34
- with cfg_path.open("r", encoding="utf-8") as f:
35
- cfg = yaml.safe_load(f) or {}
36
- except FileNotFoundError:
37
- cfg = {}
38
-
39
- labels_cfg = cfg.get("labels", {}) or {}
40
- raw_labels = [
41
- labels_cfg.get("score_1", "score_1"),
42
- labels_cfg.get("score_2", "score_2"),
43
- labels_cfg.get("score_3", "score_3"),
44
- labels_cfg.get("score_4", "score_4"),
45
- labels_cfg.get("score_5", "score_5"),
46
- labels_cfg.get("score_6", "score_6"),
47
- ]
48
- label_map = {f"score_{i+1}": raw_labels[i] for i in range(6)}
49
-
50
- order_options = {"nom": "video_name"}
51
- for i in range(6):
52
- key = f"score_{i+1}"
53
- human = raw_labels[i]
54
- order_options[human] = key
55
-
56
- with col2:
57
- order_label = st.selectbox(
58
- "Ordenar per",
59
- list(order_options.keys()),
60
- help=(
61
- "Indica el camp pel qual s'ordenen els vídeos a la taula: "
62
- "nom del vídeo o alguna de les sis característiques d'avaluació."
63
- ),
64
- )
65
-
66
- stats = get_feedback_video_stats(agg=mode_label)
67
- if not stats:
68
- st.caption("Encara no hi ha valoracions a demo/temp/feedback.db.")
69
- st.stop()
70
-
71
- df = pd.DataFrame(stats)
72
-
73
- # Ordenació segons el selector
74
- order_key = order_options[order_label]
75
- ascending = order_key == "video_name"
76
- df = df.sort_values(order_key, ascending=ascending, na_position="last")
77
-
78
- # Preparar taula per mostrar: seleccionar columnes i arrodonir valors numèrics
79
- display_cols = [
80
- "video_name",
81
- "n",
82
- "score_1",
83
- "score_2",
84
- "score_3",
85
- "score_4",
86
- "score_5",
87
- "score_6",
88
- ]
89
- df_display = df[display_cols].copy()
90
-
91
- # Arrodonir scores a la unitat (0 decimals)
92
- score_cols = [c for c in display_cols if c.startswith("score_")]
93
- df_display[score_cols] = df_display[score_cols].round(0)
94
-
95
- st.subheader("Taula agregada per vídeo")
96
- st.dataframe(
97
- df_display.rename(columns=label_map),
98
- use_container_width=True,
99
- hide_index=True,
100
- )
 
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 CHANGED
@@ -1,356 +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
- import sys
9
-
10
- import shutil
11
- import streamlit as st
12
-
13
- from databases import get_accessible_videos_with_sha1, log_event
14
- from persistent_data_gate import _load_data_origin
15
-
16
-
17
- def _log(msg: str) -> None:
18
- """Helper de logging a stderr amb timestamp (coherent amb auth.py)."""
19
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
20
- sys.stderr.write(f"[{ts}] {msg}\n")
21
- sys.stderr.flush()
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 = Path(__file__).resolve().parent.parent
39
- data_origin = _load_data_origin(base_dir)
40
-
41
- # Llista de vídeos accessibles (mode internal) o pendents al backend (mode external)
42
- session_id = st.session_state.get("session_id")
43
- accessible_rows = get_accessible_videos_with_sha1(session_id) if data_origin == "internal" else []
44
-
45
- # Rutes base per a media i vídeos pendents
46
- base_media_dir = base_dir / "temp" / "media"
47
- pending_root = base_dir / "temp" / "pending_videos"
48
-
49
- with tab_videos:
50
- st.subheader("📹 Validar Vídeos Pujats")
51
-
52
- video_folders = []
53
-
54
- # Botó per actualitzar manualment la llista de vídeos pendents des de l'engine
55
- col_refresh_list, _ = st.columns([1, 3])
56
- with col_refresh_list:
57
- if st.button("🔄 Actualitzar llista de vídeos pendents", key="refresh_pending_videos_list"):
58
- st.rerun()
59
-
60
- if data_origin == "internal":
61
- # Mode intern: llistar carpetes de vídeos pendents des de temp/pending_videos
62
- if pending_root.exists() and pending_root.is_dir():
63
- for folder in sorted(pending_root.iterdir()):
64
- if not folder.is_dir():
65
- continue
66
- sha1 = folder.name
67
- video_files = list(folder.glob("*.mp4")) + list(folder.glob("*.avi")) + list(folder.glob("*.mov"))
68
- if not video_files:
69
- continue
70
- mod_time = folder.stat().st_mtime
71
- fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
72
- video_folders.append(
73
- {
74
- "sha1sum": sha1,
75
- "video_name": sha1,
76
- "path": str(folder),
77
- "created_at": fecha,
78
- "video_files": video_files,
79
- }
80
- )
81
- else:
82
- # Mode external: llistar vídeos pendents des de l'engine
83
- api_client = st.session_state.get("api_client")
84
- if api_client is not None:
85
- try:
86
- resp = api_client.list_pending_videos()
87
- _log(f"[pending_videos] list_pending_videos raw resp type= {type(resp)}")
88
- _log(f"[pending_videos] list_pending_videos raw resp content= {repr(resp)}")
89
- except Exception as e_list:
90
- _log(f"[pending_videos] Error cridant list_pending_videos: {e_list}")
91
- resp = {"error": "exception"}
92
-
93
- pending_list = []
94
- if isinstance(resp, dict) and not resp.get("error"):
95
- # Pot ser un dict amb clau "videos" o directament una llista
96
- if isinstance(resp.get("videos"), list):
97
- pending_list = resp["videos"]
98
- elif isinstance(resp.get("items"), list):
99
- pending_list = resp["items"]
100
- elif isinstance(resp.get("results"), list):
101
- pending_list = resp["results"]
102
- elif isinstance(resp, list):
103
- pending_list = resp
104
- elif isinstance(resp, list):
105
- pending_list = resp
106
-
107
- _log(f"[pending_videos] parsed pending_list length= {len(pending_list) if isinstance(pending_list, list) else 'N/A'}")
108
- if isinstance(pending_list, list) and pending_list:
109
- _log(f"[pending_videos] first items: {pending_list[:3]}")
110
-
111
- for item in pending_list:
112
- sha1 = item.get("sha1") or item.get("video_hash") or item.get("id")
113
- if not sha1:
114
- continue
115
- video_name = item.get("latest_video") or sha1
116
- # Carpeta local on descarregarem el vídeo pendent si cal
117
- folder = pending_root / sha1
118
- if folder.exists():
119
- video_files = list(folder.glob("*.mp4"))
120
- else:
121
- video_files = []
122
- created_at = item.get("created_at") or datetime.utcnow().strftime("%Y-%m-%d %H:%M")
123
- video_folders.append(
124
- {
125
- "sha1sum": sha1,
126
- "video_name": video_name,
127
- "path": str(folder),
128
- "created_at": created_at,
129
- "video_files": video_files,
130
- }
131
- )
132
-
133
- if not video_folders:
134
- st.info("📝 No hi ha vídeos pujats pendents de validació.")
135
- else:
136
- opciones_video = [f"{video['video_name']} - {video['created_at']}" for video in video_folders]
137
- seleccion = st.selectbox(
138
- "Selecciona un vídeo per validar:",
139
- opciones_video,
140
- index=0 if opciones_video else None,
141
- )
142
-
143
- if seleccion:
144
- indice = opciones_video.index(seleccion)
145
- video_seleccionat = video_folders[indice]
146
-
147
- col1, col2 = st.columns([2, 1])
148
-
149
- with col1:
150
- st.markdown("### 📹 Informació del Vídeo")
151
- st.markdown(f"**Nom:** {video_seleccionat['video_name']}")
152
- st.markdown(f"**Data:** {video_seleccionat['created_at']}")
153
- st.markdown(f"**Arxius:** {len(video_seleccionat['video_files'])} vídeos trobats")
154
-
155
- # Assegurar que disposem del fitxer local en mode external
156
- if data_origin == "external" and not video_seleccionat["video_files"]:
157
- api_client = st.session_state.get("api_client")
158
- if api_client is not None:
159
- try:
160
- resp = api_client.download_pending_video(video_seleccionat["sha1sum"])
161
- except Exception:
162
- resp = {"error": "exception"}
163
-
164
- video_bytes = (
165
- resp.get("video_bytes")
166
- if isinstance(resp, dict)
167
- else None
168
- )
169
- if video_bytes:
170
- local_folder = pending_root / video_seleccionat["sha1sum"]
171
- local_folder.mkdir(parents=True, exist_ok=True)
172
- local_path = local_folder / "video.mp4"
173
- with local_path.open("wb") as f:
174
- f.write(video_bytes)
175
- video_seleccionat["video_files"] = [local_path]
176
-
177
- if video_seleccionat["video_files"]:
178
- video_path = str(video_seleccionat["video_files"][0])
179
- st.markdown("**Vídeo principal:**")
180
- st.video(video_path)
181
- else:
182
- st.warning("⚠️ No s'han trobat arxius de vídeo.")
183
-
184
- with col2:
185
- st.markdown("### 🔍 Accions de Validació")
186
-
187
- col_btn1, col_btn2 = st.columns(2)
188
-
189
- with col_btn1:
190
- if st.button("✅ Acceptar", type="primary", key=f"accept_video_{video_seleccionat['sha1sum']}"):
191
- # 1) Registrar decisió al servei de compliance
192
- success = compliance_client.record_validator_decision(
193
- document_id=f"video_{video_seleccionat['video_name']}",
194
- validator_email=f"{username}@veureu.local",
195
- decision="acceptat",
196
- comments=f"Vídeo validat per {username}",
197
- )
198
-
199
- # 2) Registrar esdeveniment "video approval" a events.db
200
- session_id = st.session_state.get("session_id") or ""
201
- client_ip = st.session_state.get("client_ip") or ""
202
- phone = st.session_state.get("phone_number") or ""
203
- password = st.session_state.get("password") or ""
204
-
205
- try:
206
- log_event(
207
- session=session_id,
208
- ip=client_ip,
209
- user=username or "",
210
- password=password,
211
- phone=phone,
212
- action="video approval",
213
- sha1sum=video_seleccionat["sha1sum"],
214
- visibility=None,
215
- )
216
- except Exception as e:
217
- st.warning(f"⚠️ No s'ha pogut registrar l'esdeveniment d'aprovació: {e}")
218
-
219
- if success:
220
- st.success("✅ Vídeo acceptat, registrat al servei de compliance i marcat com aprovat a events.db")
221
- else:
222
- st.error("❌ Error registrant el veredicte al servei de compliance")
223
-
224
- # 3) En mode external, moure el vídeo de temp/pending_videos a temp/media
225
- if data_origin == "external":
226
- sha1 = video_seleccionat["sha1sum"]
227
- local_pending_dir = pending_root / sha1
228
- local_media_dir = base_media_dir / sha1
229
- try:
230
- local_media_dir.mkdir(parents=True, exist_ok=True)
231
- src = local_pending_dir / "video.mp4"
232
- if src.exists():
233
- dst = local_media_dir / "video.mp4"
234
- shutil.copy2(src, dst)
235
- if local_pending_dir.exists():
236
- shutil.rmtree(local_pending_dir)
237
- except Exception:
238
- pass
239
-
240
- with col_btn2:
241
- if st.button("❌ Rebutjar", type="secondary", key=f"reject_video_{video_seleccionat['video_name']}"):
242
- success = compliance_client.record_validator_decision(
243
- document_id=f"video_{video_seleccionat['video_name']}",
244
- validator_email=f"{username}@veureu.local",
245
- decision="rebutjat",
246
- comments=f"Vídeo rebutjat per {username}",
247
- )
248
- if success:
249
- st.success("✅ Vídeo rebutjat i registrat al servei de compliance")
250
- else:
251
- st.error("❌ Error registrant el veredicte")
252
-
253
- with tab_ads:
254
- st.subheader("🎬 Validar Audiodescripcions")
255
-
256
- videos_con_ad = []
257
- for row in accessible_rows:
258
- sha1 = row["sha1sum"]
259
- video_name = row["video_name"] or row["sha1sum"]
260
- folder = base_media_dir / sha1
261
- if not folder.exists() or not folder.is_dir():
262
- continue
263
- for subfolder_name in ["MoE", "Salamandra"]:
264
- subfolder = folder / subfolder_name
265
- if subfolder.exists():
266
- ad_files = list(subfolder.glob("*_ad.txt")) + list(subfolder.glob("*_ad.srt"))
267
- if ad_files:
268
- mod_time = folder.stat().st_mtime
269
- fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
270
- videos_con_ad.append(
271
- {
272
- "sha1sum": sha1,
273
- "video_name": video_name,
274
- "path": str(folder),
275
- "created_at": fecha,
276
- "ad_files": ad_files,
277
- "ad_folder": str(subfolder),
278
- }
279
- )
280
-
281
- if not videos_con_ad:
282
- st.info("📝 No hi ha audiodescripcions pendents de validació.")
283
- else:
284
- videos_ad_ordenats = sorted(videos_con_ad, key=lambda x: x["created_at"], reverse=True)
285
- opciones_ad = [f"{video['video_name']} - {video['created_at']}" for video in videos_ad_ordenats]
286
-
287
- seleccion_ad = st.selectbox(
288
- "Selecciona una audiodescripció per validar:",
289
- opciones_ad,
290
- index=0 if opciones_ad else None,
291
- )
292
-
293
- if seleccion_ad:
294
- indice = opciones_ad.index(seleccion_ad)
295
- video_seleccionat = videos_ad_ordenats[indice]
296
-
297
- col1, col2 = st.columns([2, 1])
298
-
299
- with col1:
300
- st.markdown("### 🎬 Informació de l'Audiodescripció")
301
- st.markdown(f"**Vídeo:** {video_seleccionat['video_name']}")
302
- st.markdown(f"**Data:** {video_seleccionat['created_at']}")
303
- st.markdown(f"**Carpeta:** {Path(video_seleccionat['ad_folder']).name}")
304
- st.markdown(f"**Arxius:** {len(video_seleccionat['ad_files'])} audiodescripcions trobades")
305
-
306
- if video_seleccionat["ad_files"]:
307
- ad_path = video_seleccionat["ad_files"][0]
308
- st.markdown(f"#### 📄 Contingut ({ad_path.name}):")
309
- try:
310
- texto = ad_path.read_text(encoding="utf-8")
311
- except Exception:
312
- texto = ad_path.read_text(errors="ignore")
313
- st.text_area("Contingut de l'audiodescripció:", texto, height=300, disabled=True)
314
- else:
315
- st.warning("⚠️ No s'han trobat arxius d'audiodescripció.")
316
-
317
- with col2:
318
- st.markdown("### 🔍 Accions de Validació")
319
-
320
- col_btn1, col_btn2 = st.columns(2)
321
-
322
- with col_btn1:
323
- if st.button("✅ Acceptar", type="primary", key=f"accept_ad_{video_seleccionat['sha1sum']}"):
324
- success = compliance_client.record_validator_decision(
325
- document_id=f"ad_{video_seleccionat['video_name']}",
326
- validator_email=f"{username}@veureu.local",
327
- decision="acceptat",
328
- comments=f"Audiodescripció validada per {username}",
329
- )
330
- if success:
331
- st.success("✅ Audiodescripció acceptada i registrada al servei de compliance")
332
- else:
333
- st.error("❌ Error registrant el veredicte")
334
-
335
- with col_btn2:
336
- if st.button("❌ Rebutjar", type="secondary", key=f"reject_ad_{video_seleccionat['sha1sum']}"):
337
- success = compliance_client.record_validator_decision(
338
- document_id=f"ad_{video_seleccionat['video_name']}",
339
- validator_email=f"{username}@veureu.local",
340
- decision="rebutjat",
341
- comments=f"Audiodescripció rebutjada per {username}",
342
- )
343
- if success:
344
- st.success("✅ Audiodescripció rebutjada i registrada al servei de compliance")
345
- else:
346
- st.error("❌ Error registrant el veredicte")
347
-
348
- st.markdown("---")
349
- st.markdown("### ℹ️ Informació del Procés de Validació")
350
- st.markdown(
351
- """
352
- - **Tots els veredictes** es registren al servei de compliance per garantir la traçabilitat
353
- - **Cada validació** inclou veredicte, nom del vídeo i validador responsable
354
- - **Els registres** compleixen amb la normativa AI Act i GDPR
355
- """
356
- )
 
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
+ )