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

Upload 14 files

Browse files
aws_qldb.py CHANGED
@@ -143,6 +143,23 @@ class QLDBManager:
143
  except Exception as e:
144
  print(f"[QLDB ERROR] Error registrando consentimiento: {e}")
145
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  def record_validator_decision(self, document_id: str,
148
  validator_email: str,
 
143
  except Exception as e:
144
  print(f"[QLDB ERROR] Error registrando consentimiento: {e}")
145
  return None
146
+
147
+ def record_event(self, event: Dict[str, Any]) -> Optional[str]:
148
+ """Registra un esdeveniment generic al ledger (mode simulat).
149
+
150
+ Aquest mètode està pensat per rebre els esdeveniments procedents de
151
+ demo/databases.log_event quan la configuració de blockchain està activada.
152
+ """
153
+
154
+ try:
155
+ # Serialitzar l'esdeveniment per traça
156
+ payload = json.dumps(event, sort_keys=True, ensure_ascii=False)
157
+ simulated_id = f"event_{int(time.time())}_{hash(payload) % 10000}"
158
+ print(f"[QLDB EVENTS - SIMULATED] ID={simulated_id} payload={payload}")
159
+ return simulated_id
160
+ except Exception as e:
161
+ print(f"[QLDB EVENTS ERROR] Error registrant esdeveniment generic: {e}")
162
+ return None
163
 
164
  def record_validator_decision(self, document_id: str,
165
  validator_email: str,
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/process_video.py CHANGED
@@ -1,215 +1,1352 @@
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ó")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Només es mostra mentre el vídeo està pendent de validació humana
607
+ if (
608
+ st.session_state.get("video_uploaded")
609
+ and st.session_state.get("video_requires_validation")
610
+ and not st.session_state.get("video_validation_approved")
611
+ ):
612
+ col_status, col_refresh = st.columns([3, 1])
613
+ with col_status:
614
+ st.caption("⏳ Vídeo pendent de validació humana.")
615
+ with col_refresh:
616
+ if st.button("🔄 Actualitzar estat de validació", key="refresh_video_validation"):
617
+ # Re-sincronitzar BDs temp (inclosa events.db) des de l'origen
618
+ try:
619
+ base_dir = Path(__file__).parent.parent
620
+ api_client = st.session_state.get("api_client")
621
+ ensure_temp_databases(base_dir, api_client)
622
+ except Exception:
623
+ pass
624
+
625
+ if current_sha1:
626
+ if has_video_approval_event(current_sha1):
627
+ st.session_state.video_validation_approved = True
628
+ st.success("✅ Vídeo validat. Pots continuar amb el càsting.")
629
+ else:
630
+ st.info("Encara no s'ha registrat cap aprovació per a aquest vídeo.")
631
+
632
+ # --- 3. Carruseles de cares ---
633
+ if st.session_state.get("characters_detected") is not None:
634
+ st.markdown("---")
635
+ n_face_clusters = len(st.session_state.get("characters_detected") or [])
636
+ st.subheader(f"🖼️ Cares — clústers: {n_face_clusters}")
637
+
638
+ if n_face_clusters == 0:
639
+ st.info("No s'han detectat clústers de cara en aquest clip.")
640
+
641
+ for idx, ch in enumerate(st.session_state.characters_detected or []):
642
+ try:
643
+ folder_name = Path(ch.get("folder") or "").name
644
+ except Exception:
645
+ folder_name = ""
646
+ char_id = ch.get("id") or folder_name or f"char{idx+1}"
647
+
648
+ def _safe_key(s: str) -> str:
649
+ k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
650
+ return k or f"cluster_{idx+1}"
651
+
652
+ key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
653
+ if f"{key_prefix}_idx" not in st.session_state:
654
+ st.session_state[f"{key_prefix}_idx"] = 0
655
+ if f"{key_prefix}_discard" not in st.session_state:
656
+ st.session_state[f"{key_prefix}_discard"] = set()
657
+
658
+ faces_all = ch.get("face_files") or ([ch.get("image_url")] if ch.get("image_url") else [])
659
+ faces_all = [f for f in faces_all if f]
660
+ discard_set = st.session_state[f"{key_prefix}_discard"]
661
+ faces = [f for f in faces_all if f not in discard_set]
662
+
663
+ if not faces:
664
+ st.write(f"- {idx+1}. {ch.get('name','(sense nom)')} — sense imatges seleccionades")
665
+ continue
666
+
667
+ cur = st.session_state[f"{key_prefix}_idx"]
668
+ if cur >= len(faces):
669
+ cur = 0
670
+ st.session_state[f"{key_prefix}_idx"] = cur
671
+ fname = faces[cur]
672
+
673
+ if fname.startswith("/files/"):
674
+ img_url = f"{backend_base_url}{fname}"
675
+ else:
676
+ base = ch.get("image_url") or ""
677
+ base_dir = "/".join((base or "/").split("/")[:-1])
678
+ img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
679
+
680
+ st.markdown(f"**{idx+1}. {ch.get('name','(sense nom)')} — {ch.get('num_faces', 0)} cares**")
681
+ spacer_col, main_content_col = st.columns([0.12, 0.88])
682
+ with spacer_col:
683
+ st.write("")
684
+ with main_content_col:
685
+ media_col, form_col = st.columns([1.3, 2.7])
686
+ with media_col:
687
+ st.image(img_url, width=180)
688
+ st.caption(f"Imatge {cur+1}/{len(faces)}")
689
+ nav_prev, nav_del, nav_next = st.columns(3)
690
+ with nav_prev:
691
+ if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
692
+ st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(faces)
693
+ st.rerun()
694
+ with nav_del:
695
+ if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquesta imatge del clúster"):
696
+ st.session_state[f"{key_prefix}_discard"].add(fname)
697
+ new_list = [f for f in faces if f != fname]
698
+ new_idx = cur if cur < len(new_list) else 0
699
+ st.session_state[f"{key_prefix}_idx"] = new_idx
700
+ st.rerun()
701
+ with nav_next:
702
+ if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
703
+ st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(faces)
704
+ st.rerun()
705
+ name_key = f"{key_prefix}_name"
706
+ desc_key = f"{key_prefix}_desc"
707
+ default_name = ch.get("name", "")
708
+ default_desc = ch.get("description", "")
709
+
710
+ if default_name and (name_key not in st.session_state or not st.session_state.get(name_key)):
711
+ st.session_state[name_key] = default_name
712
+ elif name_key not in st.session_state:
713
+ st.session_state[name_key] = default_name or ""
714
+
715
+ if default_desc and (desc_key not in st.session_state or not st.session_state.get(desc_key)):
716
+ st.session_state[desc_key] = default_desc
717
+ elif desc_key not in st.session_state:
718
+ st.session_state[desc_key] = default_desc or ""
719
+
720
+ pending_desc_key = f"{key_prefix}_pending_desc"
721
+ pending_name_key = f"{key_prefix}_pending_name"
722
+ if pending_desc_key in st.session_state:
723
+ if desc_key not in st.session_state:
724
+ st.session_state[desc_key] = ""
725
+ st.session_state[desc_key] = st.session_state[pending_desc_key]
726
+ del st.session_state[pending_desc_key]
727
+
728
+ if pending_name_key in st.session_state:
729
+ if name_key not in st.session_state:
730
+ st.session_state[name_key] = ""
731
+ if not st.session_state.get(name_key):
732
+ st.session_state[name_key] = st.session_state[pending_name_key]
733
+ del st.session_state[pending_name_key]
734
+
735
+ with form_col:
736
+ st.text_input("Nom del clúster", key=name_key)
737
+ st.text_area("Descripció", key=desc_key, height=80)
738
+
739
+ if st.button("🎨 Generar descripció amb Salamandra Vision", key=f"svision_{key_prefix}"):
740
+ with st.spinner("Generant descripció..."):
741
+ from api_client import describe_image_with_svision
742
+ import requests as _req
743
+ import os as _os
744
+ import tempfile
745
+
746
+ try:
747
+ resp = _req.get(img_url, timeout=10)
748
+ if resp.status_code == 200:
749
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
750
+ tmp.write(resp.content)
751
+ tmp_path = tmp.name
752
+
753
+ try:
754
+ desc, name = describe_image_with_svision(tmp_path, is_face=True)
755
+
756
+ if desc:
757
+ st.session_state[pending_desc_key] = desc
758
+ st.success("✅ Descripció generada!")
759
+ print(f"[SVISION] Descripció generada per {char_id}: {desc[:100]}")
760
+ else:
761
+ st.warning("⚠️ No s'ha pogut generar una descripció.")
762
+ print(f"[SVISION] Descripció buida per {char_id}")
763
+
764
+ if name and not st.session_state.get(name_key):
765
+ st.session_state[pending_name_key] = name
766
+ print(f"[SVISION] Nom generat per {char_id}: {name}")
767
+
768
+ finally:
769
+ # Always clean up the temp file
770
+ try:
771
+ _os.unlink(tmp_path)
772
+ except Exception as cleanup_err:
773
+ print(f"[SVISION] Error netejant fitxer temporal: {cleanup_err}")
774
+
775
+ st.rerun()
776
+ else:
777
+ st.error(f"No s'ha pogut descarregar la imatge (status: {resp.status_code})")
778
+
779
+ except Exception as e:
780
+ st.error(f"Error generant descripció: {str(e)}")
781
+ print(f"[SVISION] Error complet: {e}")
782
+ import traceback
783
+ traceback.print_exc()
784
+
785
+ # --- 4. Carruseles de veus ---
786
+ if st.session_state.get("audio_segments") is not None:
787
+ st.markdown("---")
788
+
789
+ used_names_home = []
790
+ used_names_dona = []
791
+ noms_home_all, noms_dona_all = get_all_catalan_names()
792
+
793
+ for ch in (st.session_state.characters_detected or []):
794
+ ch_name = ch.get("name", "")
795
+ if ch_name in noms_home_all:
796
+ used_names_home.append(ch_name)
797
+ elif ch_name in noms_dona_all:
798
+ used_names_dona.append(ch_name)
799
+
800
+ segs = st.session_state.audio_segments or []
801
+ vlabels = st.session_state.voice_labels or []
802
+ valid_indices = [i for i, l in enumerate(vlabels) if isinstance(l, int) and l >= 0]
803
+ clusters = {}
804
+ for i in valid_indices:
805
+ lbl = int(vlabels[i])
806
+ clusters.setdefault(lbl, []).append(i)
807
+ n_vclusters = len(clusters)
808
+ st.subheader(f"🎙️ Empremtes de veu — clústers: {n_vclusters}")
809
+ di = st.session_state.get("diarization_info") or {}
810
+ if isinstance(di, dict) and not di.get("diarization_ok", True):
811
+ st.warning("No s'ha pogut fer la diarització amb pyannote (s'ha aplicat un sol segment de reserva).")
812
+ if not segs:
813
+ st.info("No s'han detectat mostres de veu.")
814
+ elif n_vclusters == 0:
815
+ st.info("No s'han format clústers de veu.")
816
+ else:
817
+ vname = st.session_state.video_name_from_engine
818
+ for lbl, idxs in sorted(clusters.items(), key=lambda x: x[0]):
819
+ key_prefix = f"voice_{lbl:02d}"
820
+ if f"{key_prefix}_idx" not in st.session_state:
821
+ st.session_state[f"{key_prefix}_idx"] = 0
822
+ if f"{key_prefix}_discard" not in st.session_state:
823
+ st.session_state[f"{key_prefix}_discard"] = set()
824
+ discard_set = st.session_state[f"{key_prefix}_discard"]
825
+ files = []
826
+ for i in idxs:
827
+ clip_local = (segs[i] or {}).get("clip_path")
828
+ fname = os.path.basename(clip_local) if clip_local else None
829
+ if fname:
830
+ files.append(fname)
831
+ files = [f for f in files if f and f not in discard_set]
832
+ if not files:
833
+ st.write(f"- SPEAKER_{lbl:02d} — sense clips seleccionats")
834
+ continue
835
+ cur = st.session_state[f"{key_prefix}_idx"]
836
+ if cur >= len(files):
837
+ cur = 0
838
+ st.session_state[f"{key_prefix}_idx"] = cur
839
+ fname = files[cur]
840
+ audio_url = f"{backend_base_url}/audio/{vname}/{fname}" if (vname and fname) else None
841
+ st.markdown(f"**SPEAKER_{lbl:02d} — {len(files)} clips**")
842
+ c1, c2 = st.columns([1, 2])
843
+ with c1:
844
+ if audio_url:
845
+ st.audio(audio_url, format="audio/wav")
846
+ st.caption(f"Clip {cur+1}/{len(files)}")
847
+ bcol1, bcol2, bcol3 = st.columns(3)
848
+ with bcol1:
849
+ if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
850
+ st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(files)
851
+ st.rerun()
852
+ with bcol2:
853
+ if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquest clip del clúster"):
854
+ st.session_state[f"{key_prefix}_discard"].add(fname)
855
+ new_list = [f for f in files if f != fname]
856
+ new_idx = cur if cur < len(new_list) else 0
857
+ st.session_state[f"{key_prefix}_idx"] = new_idx
858
+ st.rerun()
859
+ with bcol3:
860
+ if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
861
+ st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(files)
862
+ st.rerun()
863
+ with c2:
864
+ name_key = f"{key_prefix}_name"
865
+ desc_key = f"{key_prefix}_desc"
866
+ default_name = get_catalan_name_for_speaker(lbl, used_names_home, used_names_dona)
867
+ st.text_input("Nom del clúster", value=st.session_state.get(name_key, default_name), key=name_key)
868
+ st.text_area("Descripció", value=st.session_state.get(desc_key, ""), key=desc_key, height=80)
869
+
870
+
871
+ # --- 5. Carruseles de escenas ---
872
+ if st.session_state.get("scene_detection_done"):
873
+ st.markdown("---")
874
+ scene_clusters = st.session_state.get("scene_clusters")
875
+ n_scenes = len(scene_clusters or [])
876
+ st.subheader(f"📍 Escenes — clústers: {n_scenes}")
877
+ if not scene_clusters:
878
+ st.info("No s'han detectat clústers d'escenes en aquest clip.")
879
+ else:
880
+ for sidx, sc in enumerate(scene_clusters):
881
+ try:
882
+ folder_name = Path(sc.get("folder") or "").name
883
+ except Exception:
884
+ folder_name = ""
885
+ scene_id = sc.get("id") or folder_name or f"scene{sidx+1}"
886
+ key_prefix = re.sub(r"[^0-9a-zA-Z_]+", "_", f"scene_{sidx+1}_{scene_id}") or f"scene_{sidx+1}"
887
+ if f"{key_prefix}_idx" not in st.session_state:
888
+ st.session_state[f"{key_prefix}_idx"] = 0
889
+ if f"{key_prefix}_discard" not in st.session_state:
890
+ st.session_state[f"{key_prefix}_discard"] = set()
891
+ frames_all = sc.get("frame_files") or ([sc.get("image_url")] if sc.get("image_url") else [])
892
+ frames_all = [f for f in frames_all if f]
893
+ discard_set = st.session_state[f"{key_prefix}_discard"]
894
+ frames = [f for f in frames_all if f not in discard_set]
895
+ if not frames:
896
+ st.write(f"- {sidx+1}. (sense imatges de l'escena)")
897
+ continue
898
+ cur = st.session_state[f"{key_prefix}_idx"]
899
+ if cur >= len(frames):
900
+ cur = 0
901
+ st.session_state[f"{key_prefix}_idx"] = cur
902
+ fname = frames[cur]
903
+ if str(fname).startswith("/files/"):
904
+ img_url = f"{backend_base_url}{fname}"
905
+ else:
906
+ base = sc.get("image_url") or ""
907
+ base_dir = "/".join((base or "/").split("/")[:-1])
908
+ img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
909
+ st.markdown(f"**{sidx+1}. Escena — {sc.get('num_frames', 0)} frames**")
910
+ spacer_col, main_content_col = st.columns([0.12, 0.88])
911
+ with spacer_col:
912
+ st.write("")
913
+ with main_content_col:
914
+ media_col, form_col = st.columns([1.4, 2.6])
915
+ with media_col:
916
+ st.image(img_url, width=220)
917
+ st.caption(f"Imatge {cur+1}/{len(frames)}")
918
+ nav_prev, nav_del, nav_next = st.columns(3)
919
+ with nav_prev:
920
+ if st.button("⬅️", key=f"prev_{key_prefix}", help="Anterior"):
921
+ st.session_state[f"{key_prefix}_idx"] = (cur - 1) % len(frames)
922
+ st.rerun()
923
+ with nav_del:
924
+ if st.button("🗑️", key=f"del_{key_prefix}", help="Eliminar aquesta imatge del clúster"):
925
+ st.session_state[f"{key_prefix}_discard"].add(fname)
926
+ new_list = [f for f in frames if f != fname]
927
+ new_idx = cur if cur < len(new_list) else 0
928
+ st.session_state[f"{key_prefix}_idx"] = new_idx
929
+ st.rerun()
930
+ with nav_next:
931
+ if st.button("➡️", key=f"next_{key_prefix}", help="Següent"):
932
+ st.session_state[f"{key_prefix}_idx"] = (cur + 1) % len(frames)
933
+ st.rerun()
934
+ name_key = f"{key_prefix}_name"
935
+ desc_key = f"{key_prefix}_desc"
936
+ default_scene_name = sc.get("name", "")
937
+ default_scene_desc = sc.get("description", "")
938
+
939
+ if default_scene_name and (name_key not in st.session_state or not st.session_state.get(name_key)):
940
+ st.session_state[name_key] = default_scene_name
941
+ elif name_key not in st.session_state:
942
+ st.session_state[name_key] = default_scene_name or ""
943
+
944
+ if default_scene_desc and (desc_key not in st.session_state or not st.session_state.get(desc_key)):
945
+ st.session_state[desc_key] = default_scene_desc
946
+ elif desc_key not in st.session_state:
947
+ st.session_state[desc_key] = default_scene_desc or ""
948
+
949
+ pending_desc_key = f"{key_prefix}_pending_desc"
950
+ pending_name_key = f"{key_prefix}_pending_name"
951
+ if pending_desc_key in st.session_state:
952
+ if desc_key not in st.session_state:
953
+ st.session_state[desc_key] = ""
954
+ st.session_state[desc_key] = st.session_state[pending_desc_key]
955
+ del st.session_state[pending_desc_key]
956
+
957
+ if pending_name_key in st.session_state:
958
+ if name_key not in st.session_state:
959
+ st.session_state[name_key] = ""
960
+ if not st.session_state.get(name_key):
961
+ st.session_state[name_key] = st.session_state[pending_name_key]
962
+ del st.session_state[pending_name_key]
963
+
964
+ with form_col:
965
+ st.text_input("Nom del clúster", key=name_key)
966
+ st.text_area("Descripció", key=desc_key, height=80)
967
+
968
+ if st.button("🎨 Generar descripció amb Salamandra Vision", key=f"svision_{key_prefix}"):
969
+ with st.spinner("Generant descripció..."):
970
+ from api_client import describe_image_with_svision, generate_short_scene_name
971
+ import requests as _req
972
+ import os as _os
973
+ import tempfile
974
+
975
+ try:
976
+ resp = _req.get(img_url, timeout=10)
977
+ if resp.status_code == 200:
978
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
979
+ tmp.write(resp.content)
980
+ tmp_path = tmp.name
981
+
982
+ try:
983
+ desc, name = describe_image_with_svision(tmp_path, is_face=False)
984
+
985
+ if desc:
986
+ st.session_state[pending_desc_key] = desc
987
+ print(f"[SVISION] Descripció d'escena generada per {scene_id}: {desc[:100]}")
988
+
989
+ try:
990
+ short_name = generate_short_scene_name(desc)
991
+ if short_name:
992
+ st.session_state[pending_name_key] = short_name
993
+ print(f"[SCHAT] Nom curt generat: {short_name}")
994
+ elif name:
995
+ st.session_state[pending_name_key] = name
996
+ print(f"[SVISION] Usant nom original: {name}")
997
+ except Exception as schat_err:
998
+ print(f"[SCHAT] Error: {schat_err}")
999
+ if name:
1000
+ st.session_state[pending_name_key] = name
1001
+ print(f"[SVISION] Usant nom original fallback: {name}")
1002
+
1003
+ st.success("✅ Descripció i nom generats!")
1004
+ else:
1005
+ st.warning("⚠️ No s'ha pogut generar una descripció.")
1006
+ print(f"[SVISION] Descripció d'escena buida per {scene_id}")
1007
+
1008
+ finally:
1009
+ # Always clean up the temp file
1010
+ try:
1011
+ _os.unlink(tmp_path)
1012
+ except Exception as cleanup_err:
1013
+ print(f"[SVISION] Error netejant fitxer temporal: {cleanup_err}")
1014
+
1015
+ st.rerun()
1016
+ else:
1017
+ st.error(f"No s'ha pogut descarregar la imatge (status: {resp.status_code})")
1018
+
1019
+ except Exception as e:
1020
+ st.error(f"Error generant descripció: {str(e)}")
1021
+ print(f"[SVISION] Error complet: {e}")
1022
+ import traceback
1023
+ traceback.print_exc()
1024
+
1025
+ # --- 6. Confirmación de casting y personajes combinados ---
1026
+ if st.session_state.get("detect_done"):
1027
+ st.markdown("---")
1028
+ colc1, colc2 = st.columns([1,1])
1029
+ with colc1:
1030
+ if st.button("Confirmar càsting definitiu", type="primary"):
1031
+ chars_payload = []
1032
+ for idx, ch in enumerate(st.session_state.characters_detected or []):
1033
+ try:
1034
+ folder_name = Path(ch.get("folder") or "").name
1035
+ except Exception:
1036
+ folder_name = ""
1037
+ char_id = ch.get("id") or folder_name or f"char{idx+1}"
1038
+ def _safe_key(s: str) -> str:
1039
+ k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
1040
+ return k or f"cluster_{idx+1}"
1041
+ key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
1042
+ name = st.session_state.get(f"{key_prefix}_name") or ch.get("name") or f"Personatge {idx+1}"
1043
+ desc = st.session_state.get(f"{key_prefix}_desc", "")
1044
+ faces_all = ch.get("face_files") or []
1045
+ discard = st.session_state.get(f"{key_prefix}_discard", set())
1046
+ kept = [f for f in faces_all if f and f not in discard]
1047
+ chars_payload.append({
1048
+ "id": char_id,
1049
+ "name": name,
1050
+ "description": desc,
1051
+ "folder": ch.get("folder"),
1052
+ "kept_files": kept,
1053
+ })
1054
+
1055
+ used_names_home_fin = []
1056
+ used_names_dona_fin = []
1057
+ noms_home_all, noms_dona_all = get_all_catalan_names()
1058
+ for cp in chars_payload:
1059
+ face_name = cp.get("name", "")
1060
+ if face_name in noms_home_all:
1061
+ used_names_home_fin.append(face_name)
1062
+ elif face_name in noms_dona_all:
1063
+ used_names_dona_fin.append(face_name)
1064
+
1065
+ segs = st.session_state.audio_segments or []
1066
+ vlabels = st.session_state.voice_labels or []
1067
+ vname = st.session_state.video_name_from_engine
1068
+ voice_clusters = {}
1069
+ for i, seg in enumerate(segs):
1070
+ lbl = vlabels[i] if i < len(vlabels) else -1
1071
+ # Només considerem clústers de veu amb etiqueta vàlida (enter >= 0)
1072
+ if not (isinstance(lbl, int) and lbl >= 0):
1073
+ continue
1074
+ clip_local = seg.get("clip_path")
1075
+ fname = os.path.basename(clip_local) if clip_local else None
1076
+ if fname:
1077
+ default_voice_name = get_catalan_name_for_speaker(int(lbl), used_names_home_fin, used_names_dona_fin)
1078
+ voice_clusters.setdefault(lbl, {"label": lbl, "name": default_voice_name, "description": "", "clips": []})
1079
+ vpref = f"voice_{int(lbl):02d}"
1080
+ vname_custom = st.session_state.get(f"{vpref}_name")
1081
+ vdesc_custom = st.session_state.get(f"{vpref}_desc")
1082
+ if vname_custom:
1083
+ voice_clusters[lbl]["name"] = vname_custom
1084
+ if vdesc_custom is not None:
1085
+ voice_clusters[lbl]["description"] = vdesc_custom
1086
+ voice_clusters[lbl]["clips"].append(fname)
1087
+
1088
+ payload = {
1089
+ "video_name": vname,
1090
+ "base_dir": st.session_state.get("engine_base_dir"),
1091
+ "characters": chars_payload,
1092
+ "voice_clusters": list(voice_clusters.values()),
1093
+ }
1094
+
1095
+ if not payload["video_name"] or not payload["base_dir"]:
1096
+ st.error("Falten dades del vídeo per confirmar el càsting (video_name/base_dir). Torna a processar el vídeo.")
1097
+ else:
1098
+ with st.spinner("Consolidant càsting al servidor…"):
1099
+ res_fc = api.finalize_casting(payload)
1100
+ if isinstance(res_fc, dict) and res_fc.get("ok"):
1101
+ st.success(f"Càsting consolidat. Identities: {len(res_fc.get('face_identities', []))} cares, {len(res_fc.get('voice_identities', []))} veus.")
1102
+ st.session_state.casting_finalized = True
1103
+
1104
+ f_id = res_fc.get('face_identities', []) or []
1105
+ v_id = res_fc.get('voice_identities', []) or []
1106
+ c3, c4 = st.columns(2)
1107
+ with c3:
1108
+ st.markdown("**Identitats de cara**")
1109
+ for n in f_id:
1110
+ st.write(f"- {n}")
1111
+ with c4:
1112
+ st.markdown("**Identitats de veu**")
1113
+ for n in v_id:
1114
+ st.write(f"- {n}")
1115
+
1116
+ faces_dir = res_fc.get('faces_dir')
1117
+ voices_dir = res_fc.get('voices_dir')
1118
+ db_dir = res_fc.get('db_dir')
1119
+ with st.spinner("Carregant índexs al cercador (Chroma)…"):
1120
+ load_res = api.load_casting(faces_dir=faces_dir, voices_dir=voices_dir, db_dir=db_dir, drop_collections=True)
1121
+ if isinstance(load_res, dict) and load_res.get('ok'):
1122
+ st.success(f"Índexs carregats: {load_res.get('faces', 0)} cares, {load_res.get('voices', 0)} veus.")
1123
+ else:
1124
+ st.error(f"Error carregant índexs: {load_res}")
1125
+ else:
1126
+ st.error(f"No s'ha pogut consolidar el càsting: {res_fc}")
1127
+
1128
+ # --- Personatges combinats (cares + veus) ---
1129
+ if st.session_state.get("casting_finalized"):
1130
+ st.markdown("---")
1131
+ st.subheader("👥 Personatges")
1132
+
1133
+ def normalize_name(name: str) -> str:
1134
+ import unicodedata
1135
+ name_upper = name.upper()
1136
+ name_normalized = ''.join(
1137
+ c for c in unicodedata.normalize('NFD', name_upper)
1138
+ if unicodedata.category(c) != 'Mn'
1139
+ )
1140
+ return name_normalized
1141
+
1142
+ chars_payload = []
1143
+ for idx, ch in enumerate(st.session_state.characters_detected or []):
1144
+ try:
1145
+ folder_name = Path(ch.get("folder") or "").name
1146
+ except Exception:
1147
+ folder_name = ""
1148
+ char_id = ch.get("id") or folder_name or f"char{idx+1}"
1149
+ def _safe_key(s: str) -> str:
1150
+ k = re.sub(r"[^0-9a-zA-Z_]+", "_", s or "")
1151
+ return k or f"cluster_{idx+1}"
1152
+ key_prefix = _safe_key(f"char_{idx+1}_{char_id}")
1153
+ name = st.session_state.get(f"{key_prefix}_name") or ch.get("name") or f"Personatge {idx+1}"
1154
+ name_normalized = normalize_name(name)
1155
+ desc = st.session_state.get(f"{key_prefix}_desc", "").strip()
1156
+ chars_payload.append({
1157
+ "name": name,
1158
+ "name_normalized": name_normalized,
1159
+ "face_key_prefix": key_prefix,
1160
+ "face_files": ch.get("face_files") or [],
1161
+ "char_data": ch,
1162
+ "description": desc,
1163
+ })
1164
+
1165
+ used_names_home_pers = []
1166
+ used_names_dona_pers = []
1167
+ noms_home_all, noms_dona_all = get_all_catalan_names()
1168
+ for cp in chars_payload:
1169
+ face_name = cp.get("name", "")
1170
+ if face_name in noms_home_all:
1171
+ used_names_home_pers.append(face_name)
1172
+ elif face_name in noms_dona_all:
1173
+ used_names_dona_pers.append(face_name)
1174
+
1175
+ segs = st.session_state.audio_segments or []
1176
+ vlabels = st.session_state.voice_labels or []
1177
+ vname = st.session_state.video_name_from_engine
1178
+ voice_clusters_by_name = {}
1179
+ for i, seg in enumerate(segs):
1180
+ lbl = vlabels[i] if i < len(vlabels) else -1
1181
+ if not (isinstance(lbl, int) and lbl >= 0):
1182
+ continue
1183
+ vpref = f"voice_{int(lbl):02d}"
1184
+ 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}"
1185
+ vname_custom = st.session_state.get(f"{vpref}_name") or default_voice_name
1186
+ vname_normalized = normalize_name(vname_custom)
1187
+ vdesc = st.session_state.get(f"{vpref}_desc", "").strip()
1188
+ clip_local = seg.get("clip_path")
1189
+ fname = os.path.basename(clip_local) if clip_local else None
1190
+ if fname:
1191
+ voice_clusters_by_name.setdefault(vname_normalized, {
1192
+ "voice_key_prefix": vpref,
1193
+ "clips": [],
1194
+ "label": lbl,
1195
+ "original_name": vname_custom,
1196
+ "description": vdesc,
1197
+ })
1198
+ voice_clusters_by_name[vname_normalized]["clips"].append(fname)
1199
+
1200
+ all_normalized_names = set([c["name_normalized"] for c in chars_payload] + list(voice_clusters_by_name.keys()))
1201
+
1202
+ for pidx, norm_name in enumerate(sorted(all_normalized_names)):
1203
+ face_items = [c for c in chars_payload if c["name_normalized"] == norm_name]
1204
+ voice_data = voice_clusters_by_name.get(norm_name)
1205
+
1206
+ display_name = face_items[0]["name"] if face_items else (voice_data["original_name"] if voice_data else norm_name)
1207
+
1208
+ descriptions = []
1209
+ for face_item in face_items:
1210
+ if face_item["description"]:
1211
+ descriptions.append(face_item["description"])
1212
+ if voice_data and voice_data.get("description"):
1213
+ descriptions.append(voice_data["description"])
1214
+
1215
+ combined_description = "\n".join(descriptions) if descriptions else ""
1216
+
1217
+ st.markdown(f"**{pidx+1}. {display_name}**")
1218
+
1219
+ all_faces = []
1220
+ for face_item in face_items:
1221
+ all_faces.extend(face_item["face_files"])
1222
+
1223
+ face_data = face_items[0] if face_items else None
1224
+
1225
+ col_faces, col_voices, col_text = st.columns([1, 1, 1.5])
1226
+
1227
+ with col_faces:
1228
+ if all_faces:
1229
+ carousel_key = f"combined_face_{pidx}"
1230
+ if f"{carousel_key}_idx" not in st.session_state:
1231
+ st.session_state[f"{carousel_key}_idx"] = 0
1232
+ cur = st.session_state[f"{carousel_key}_idx"]
1233
+ if cur >= len(all_faces):
1234
+ cur = 0
1235
+ st.session_state[f"{carousel_key}_idx"] = cur
1236
+ fname = all_faces[cur]
1237
+ ch = face_data["char_data"] if face_data else {}
1238
+ if fname.startswith("/files/"):
1239
+ img_url = f"{backend_base_url}{fname}"
1240
+ else:
1241
+ base = ch.get("image_url") or ""
1242
+ base_dir = "/".join((base or "/").split("/")[:-1])
1243
+ img_url = f"{backend_base_url}{base_dir}/{fname}" if base_dir else f"{backend_base_url}{fname}"
1244
+ st.image(img_url, width=150)
1245
+ st.caption(f"Cara {cur+1}/{len(all_faces)}")
1246
+ bcol1, bcol2 = st.columns(2)
1247
+ with bcol1:
1248
+ if st.button("⬅️", key=f"combined_face_prev_{pidx}"):
1249
+ st.session_state[f"{carousel_key}_idx"] = (cur - 1) % len(all_faces)
1250
+ st.rerun()
1251
+ with bcol2:
1252
+ if st.button("➡️", key=f"combined_face_next_{pidx}"):
1253
+ st.session_state[f"{carousel_key}_idx"] = (cur + 1) % len(all_faces)
1254
+ st.rerun()
1255
+ else:
1256
+ st.info("Sense imatges")
1257
+
1258
+ with col_voices:
1259
+ if voice_data:
1260
+ clips = voice_data["clips"]
1261
+ if clips:
1262
+ carousel_key = f"combined_voice_{pidx}"
1263
+ if f"{carousel_key}_idx" not in st.session_state:
1264
+ st.session_state[f"{carousel_key}_idx"] = 0
1265
+ cur = st.session_state[f"{carousel_key}_idx"]
1266
+ if cur >= len(clips):
1267
+ cur = 0
1268
+ st.session_state[f"{carousel_key}_idx"] = cur
1269
+ fname = clips[cur]
1270
+ audio_url = f"{backend_base_url}/audio/{vname}/{fname}" if (vname and fname) else None
1271
+ if audio_url:
1272
+ st.audio(audio_url, format="audio/wav")
1273
+ st.caption(f"Veu {cur+1}/{len(clips)}")
1274
+ bcol1, bcol2 = st.columns(2)
1275
+ with bcol1:
1276
+ if st.button("⬅️", key=f"combined_voice_prev_{pidx}"):
1277
+ st.session_state[f"{carousel_key}_idx"] = (cur - 1) % len(clips)
1278
+ st.rerun()
1279
+ with bcol2:
1280
+ if st.button("➡️", key=f"combined_voice_next_{pidx}"):
1281
+ st.session_state[f"{carousel_key}_idx"] = (cur + 1) % len(clips)
1282
+ st.rerun()
1283
+ else:
1284
+ st.info("Sense clips de veu")
1285
+ else:
1286
+ st.info("Sense dades de veu")
1287
+
1288
+ with col_text:
1289
+ combined_name_key = f"combined_char_{pidx}_name"
1290
+ combined_desc_key = f"combined_char_{pidx}_desc"
1291
+
1292
+ if combined_name_key not in st.session_state:
1293
+ st.session_state[combined_name_key] = norm_name
1294
+ if combined_desc_key not in st.session_state:
1295
+ st.session_state[combined_desc_key] = combined_description
1296
+
1297
+ st.text_input("Nom del personatge", key=combined_name_key, label_visibility="collapsed", placeholder="Nom del personatge")
1298
+ st.text_area("Descripció", key=combined_desc_key, height=120, label_visibility="collapsed", placeholder="Descripció del personatge")
1299
+
1300
+ # --- 7. Generar audiodescripció ---
1301
+ st.markdown("---")
1302
+ if st.button("🎬 Generar audiodescripció", type="primary", use_container_width=True):
1303
+ v = st.session_state.get("video_uploaded")
1304
+ if not v:
1305
+ st.error("No hi ha cap vídeo carregat.")
1306
+ else:
1307
+ progress_placeholder = st.empty()
1308
+ result_placeholder = st.empty()
1309
+
1310
+ with st.spinner("Generant audiodescripció... Aquest procés pot trigar diversos minuts."):
1311
+ progress_placeholder.info("⏳ Processant vídeo i generant audiodescripció UNE-153010...")
1312
+
1313
+ try:
1314
+ out = api.generate_audiodescription(v["bytes"], v["name"])
1315
+
1316
+ if isinstance(out, dict) and out.get("status") == "done":
1317
+ progress_placeholder.success("✅ Audiodescripció generada correctament!")
1318
+ res = out.get("results", {})
1319
+
1320
+ with result_placeholder.container():
1321
+ st.success("🎉 Audiodescripció completada!")
1322
+ c1, c2 = st.columns([1,1])
1323
+ with c1:
1324
+ st.markdown("**📄 UNE-153010 SRT**")
1325
+ une_srt_content = res.get("une_srt", "")
1326
+ st.code(une_srt_content, language="text")
1327
+ if une_srt_content:
1328
+ st.download_button(
1329
+ "⬇️ Descarregar UNE SRT",
1330
+ data=une_srt_content,
1331
+ file_name=f"{v['name']}_une.srt",
1332
+ mime="text/plain"
1333
+ )
1334
+ with c2:
1335
+ st.markdown("**📝 Narració lliure**")
1336
+ free_text_content = res.get("free_text", "")
1337
+ st.text_area("", value=free_text_content, height=240, key="free_text_result")
1338
+ if free_text_content:
1339
+ st.download_button(
1340
+ "⬇️ Descarregar text lliure",
1341
+ data=free_text_content,
1342
+ file_name=f"{v['name']}_free.txt",
1343
+ mime="text/plain"
1344
+ )
1345
+ else:
1346
+ progress_placeholder.empty()
1347
+ error_msg = str(out.get("error", out)) if isinstance(out, dict) else str(out)
1348
+ result_placeholder.error(f"❌ Error generant l'audiodescripció: {error_msg}")
1349
+
1350
+ except Exception as e:
1351
+ progress_placeholder.empty()
1352
+ result_placeholder.error(f"❌ Excepció durant la generació: {e}")
page_modules/statistics.py CHANGED
@@ -1,46 +1,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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )
page_modules/validation.py CHANGED
@@ -1,221 +1,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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )