VeuReu commited on
Commit
4d2feb8
·
1 Parent(s): 377660c

Upload 9 files

Browse files
app.py CHANGED
@@ -1,65 +1,23 @@
1
  import os
2
- import io
3
- import json
4
  import yaml
5
  import shutil
6
- import subprocess
7
  from pathlib import Path
8
- from datetime import datetime
9
- from passlib.hash import bcrypt
10
  try:
11
  import tomllib
12
  except ModuleNotFoundError: # Py<3.11
13
  import tomli as tomllib
14
  import streamlit as st
15
 
16
- from database import set_db_path, init_schema, create_video, update_video_status, list_videos, get_video, upsert_result, get_results, add_feedback, get_feedback_for_video, get_feedback_stats
17
  from api_client import APIClient
18
- from utils import ensure_dirs, save_bytes, save_text, human_size
19
  from auth import initialize_auth_system, render_login_form, render_sidebar, require_login
20
  from mobile_verification import render_mobile_verification_screen, get_user_permissions
21
  from compliance_client import compliance_client
22
-
23
-
24
- def _get_video_duration(path: str) -> float:
25
- """Return video duration in seconds using ffprobe."""
26
- cmd = [
27
- "ffprobe",
28
- "-v",
29
- "error",
30
- "-show_entries",
31
- "format=duration",
32
- "-of",
33
- "default=noprint_wrappers=1:nokey=1",
34
- path,
35
- ]
36
- try:
37
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
38
- return float(result.stdout.strip())
39
- except (subprocess.CalledProcessError, ValueError):
40
- return 0.0
41
-
42
-
43
- def _transcode_video(input_path: str, output_path: str, max_duration: int | None = None) -> None:
44
- cmd = ["ffmpeg", "-y", "-i", input_path]
45
- if max_duration is not None:
46
- cmd += ["-t", str(max_duration)]
47
- cmd += [
48
- "-c:v",
49
- "libx264",
50
- "-preset",
51
- "veryfast",
52
- "-crf",
53
- "23",
54
- "-c:a",
55
- "aac",
56
- "-movflags",
57
- "+faststart",
58
- output_path,
59
- ]
60
- result = subprocess.run(cmd, capture_output=True, text=True)
61
- if result.returncode != 0:
62
- raise RuntimeError(result.stderr.strip() or "ffmpeg failed")
63
 
64
 
65
  # -- Move DB ---
@@ -136,6 +94,7 @@ if not st.session_state.user:
136
  if st.session_state.user and 'sms_verified' not in st.session_state:
137
  st.session_state.sms_verified = None
138
 
 
139
  if st.session_state.user:
140
  username = st.session_state.user['username']
141
  role = st.session_state.user['role']
@@ -155,583 +114,23 @@ if st.session_state.user:
155
  if page == "Processar vídeo nou":
156
  require_login(render_login_form)
157
 
158
- # Verificar permisos
159
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
160
  if not permissions["procesar_videos"]:
161
  st.error("No tens permisos per processar nous vídeos. Verifica el teu mòbil per obtenir accés complet.")
162
  st.stop()
163
 
164
- st.header("Processar un nou clip de vídeo")
165
-
166
- # Inicializar el estado de la página si no existe
167
- if 'video_uploaded' not in st.session_state:
168
- st.session_state.video_uploaded = None
169
- if 'characters_detected' not in st.session_state:
170
- st.session_state.characters_detected = None
171
- if 'characters_saved' not in st.session_state:
172
- st.session_state.characters_saved = False
173
-
174
- # --- 1. Subida del vídeo ---
175
- MAX_SIZE_MB = 20
176
- MAX_DURATION_S = 240 # 4 minutos
177
-
178
- uploaded_file = st.file_uploader("Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)", type=["mp4"], key="video_uploader")
179
-
180
- if uploaded_file is not None:
181
- # Resetear el estado si se sube un nuevo archivo
182
- if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get('original_name'):
183
- st.session_state.video_uploaded = {'original_name': uploaded_file.name, 'status': 'validating'}
184
- st.session_state.characters_detected = None
185
- st.session_state.characters_saved = False
186
-
187
- # --- Validación y Procesamiento ---
188
- if st.session_state.video_uploaded['status'] == 'validating':
189
- is_valid = True
190
- # 1. Validar tamaño
191
- if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
192
- st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
193
- is_valid = False
194
-
195
- if is_valid:
196
- with st.spinner("Processant el vídeo..."):
197
- # Guardar temporalmente para analizarlo
198
- temp_path = Path("temp_video.mp4")
199
- with temp_path.open("wb") as f:
200
- f.write(uploaded_file.getbuffer())
201
-
202
- was_truncated = False
203
- final_video_path = None
204
- try:
205
- duration = _get_video_duration(str(temp_path))
206
- if not duration:
207
- st.error("No s'ha pogut obtenir la durada del vídeo.")
208
- is_valid = False
209
-
210
- if is_valid:
211
- if duration > MAX_DURATION_S:
212
- was_truncated = True
213
-
214
- video_name = Path(uploaded_file.name).stem
215
- video_dir = Path("/tmp/data/videos") / video_name
216
- video_dir.mkdir(parents=True, exist_ok=True)
217
- final_video_path = video_dir / f"{video_name}.mp4"
218
-
219
- try:
220
- _transcode_video(
221
- str(temp_path),
222
- str(final_video_path),
223
- MAX_DURATION_S if was_truncated else None,
224
- )
225
- except RuntimeError as exc:
226
- st.error(f"No s'ha pogut processar el vídeo: {exc}")
227
- is_valid = False
228
-
229
- if is_valid and final_video_path is not None:
230
- st.session_state.video_uploaded.update({
231
- 'status': 'processed',
232
- 'path': str(final_video_path),
233
- 'was_truncated': was_truncated
234
- })
235
- st.rerun()
236
- finally:
237
- if temp_path.exists():
238
- temp_path.unlink()
239
-
240
- # --- Mensajes de estado ---
241
- if st.session_state.video_uploaded and st.session_state.video_uploaded['status'] == 'processed':
242
- st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
243
- if st.session_state.video_uploaded['was_truncated']:
244
- st.warning(f"El vídeo s'ha truncat a {MAX_DURATION_S // 60} minuts.")
245
-
246
- # --- 2. Detección de personajes ---
247
- st.markdown("---")
248
- col1, col2 = st.columns([1, 3])
249
- with col1:
250
- detect_button_disabled = st.session_state.video_uploaded is None
251
- if st.button("Detectar Personatges", disabled=detect_button_disabled):
252
- with st.spinner("Detectant personatges..."):
253
- # Aquí iría la llamada a la API para detectar personajes
254
- # Por ahora, usamos datos de ejemplo
255
- st.session_state.characters_detected = [
256
- {"id": "char1", "image_path": "init_data/placeholder.png", "description": "Dona amb cabell ros i ulleres"},
257
- {"id": "char2", "image_path": "init_data/placeholder.png", "description": "Home amb barba i barret"},
258
- ]
259
- st.session_state.characters_saved = False # Resetear el estado de guardado
260
-
261
- # --- 3. Formularios de personajes ---
262
- if st.session_state.characters_detected:
263
- st.subheader("Personatges detectats")
264
- for char in st.session_state.characters_detected:
265
- with st.form(key=f"form_{char['id']}"):
266
- col1, col2 = st.columns(2)
267
- with col1:
268
- st.image(char['image_path'], width=150)
269
-
270
- with col2:
271
- st.caption(char['description'])
272
- st.text_input("Nom del personatge", key=f"name_{char['id']}")
273
- st.form_submit_button("Cercar")
274
-
275
- st.markdown("---_**")
276
-
277
- # --- 4. Guardar y Generar ---
278
- col1, col2, col3 = st.columns([1,1,2])
279
- with col1:
280
- if st.button("Desar", type="primary"):
281
- # Aquí iría la lógica para guardar los nombres de los personajes
282
- st.session_state.characters_saved = True
283
- st.success("Personatges desats correctament.")
284
-
285
- with col2:
286
- if st.session_state.characters_saved:
287
- st.button("Generar Audiodescripció")
288
 
289
  elif page == "Analitzar video-transcripcions":
290
  require_login(render_login_form)
291
- st.header("Analitzar video-transcripcions")
292
- base_dir = Path("/tmp/data/videos")
293
-
294
- if not base_dir.exists():
295
- st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
296
- st.stop()
297
-
298
- carpetes = [p.name for p in sorted(base_dir.iterdir()) if p.is_dir() and p.name != 'completed']
299
- if not carpetes:
300
- st.info("No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
301
- st.stop()
302
-
303
- # --- Lógica de Estado y Selección ---
304
-
305
- # Detectar si el vídeo principal ha cambiado para resetear el estado secundario
306
- if 'current_video' not in st.session_state:
307
- st.session_state.current_video = None
308
-
309
- # Widget de selección de vídeo
310
- seleccio = st.selectbox("Selecciona un vídeo (carpeta):", carpetes, index=None, placeholder="Tria una carpeta…")
311
-
312
- if seleccio != st.session_state.current_video:
313
- st.session_state.current_video = seleccio
314
- # Forzar reseteo de los widgets dependientes
315
- if 'version_selector' in st.session_state:
316
- del st.session_state['version_selector']
317
- st.session_state.add_ad_checkbox = False
318
- st.rerun()
319
-
320
- if not seleccio:
321
- st.stop()
322
-
323
- vid_dir = base_dir / seleccio
324
- mp4s = sorted(vid_dir.glob("*.mp4"))
325
-
326
- # --- Dibujado de la Interfaz ---
327
- col_video, col_txt = st.columns([2, 1], gap="large")
328
-
329
- with col_video:
330
- # Selección de versión
331
- subcarpetas_ad = [p.name for p in sorted(vid_dir.iterdir()) if p.is_dir()]
332
- default_index_sub = subcarpetas_ad.index("Salamandra") if "Salamandra" in subcarpetas_ad else 0
333
- subcarpeta_seleccio = st.selectbox(
334
- "Selecciona una versió d'audiodescripció:", subcarpetas_ad,
335
- index=default_index_sub if subcarpetas_ad else None,
336
- placeholder="Tria una versió…" if subcarpetas_ad else "No hi ha versions",
337
- key="version_selector"
338
- )
339
-
340
- # Lógica de vídeo AD
341
- video_ad_path = vid_dir / subcarpeta_seleccio / "une_ad.mp4" if subcarpeta_seleccio else None
342
- is_ad_video_available = video_ad_path is not None and video_ad_path.exists()
343
-
344
- # Checkbox
345
- add_ad_video = st.checkbox("Afegir audiodescripció", disabled=not is_ad_video_available, key="add_ad_checkbox")
346
-
347
- # Decidir qué vídeo mostrar
348
- video_to_show = None
349
- if add_ad_video and is_ad_video_available:
350
- video_to_show = video_ad_path
351
- elif mp4s:
352
- video_to_show = mp4s[0]
353
-
354
- if video_to_show:
355
- st.video(str(video_to_show))
356
- else:
357
- st.warning("No s'ha trobat cap fitxer **.mp4** a la carpeta seleccionada.")
358
-
359
- st.markdown("---")
360
-
361
- # Sección de ACCIONES
362
- st.markdown("#### Accions")
363
- c1, c2 = st.columns(2)
364
- with c1:
365
- if st.button("Reconstruir àudio amb narració lliure", use_container_width=True, key="rebuild_free_ad"):
366
- if subcarpeta_seleccio:
367
- free_ad_path = vid_dir / subcarpeta_seleccio / "free_ad.txt"
368
- if free_ad_path.exists():
369
- with st.spinner("Generant àudio de la narració lliure..."):
370
- text_content = free_ad_path.read_text(encoding="utf-8")
371
- voice = "central/grau" # Voz fijada
372
- response = api.tts_matxa(text=text_content, voice=voice)
373
- if "mp3_bytes" in response:
374
- output_path = vid_dir / subcarpeta_seleccio / "free_ad.mp3"
375
- save_bytes(output_path, response["mp3_bytes"])
376
- st.success(f"Àudio generat i desat a: {output_path}")
377
- else:
378
- st.error(f"Error en la generació de l'àudio: {response.get('error', 'Desconegut')}")
379
- else:
380
- st.warning("No s'ha trobat el fitxer 'free_ad.txt' en aquesta versió.")
381
-
382
- with c2:
383
- if st.button("Reconstruir vídeo amb audiodescripció", use_container_width=True, key="rebuild_video_ad"):
384
- if subcarpeta_seleccio and mp4s:
385
- une_srt_path = vid_dir / subcarpeta_seleccio / "une_ad.srt"
386
- video_original_path = mp4s[0]
387
- if une_srt_path.exists():
388
- with st.spinner("Reconstruint el vídeo amb l'audiodescripció... Aquesta operació pot trigar una estona."):
389
- response = api.rebuild_video_with_ad(video_path=str(video_original_path), srt_path=str(une_srt_path))
390
- if "video_bytes" in response:
391
- output_path = vid_dir / subcarpeta_seleccio / "video_ad_rebuilt.mp4"
392
- save_bytes(output_path, response["video_bytes"])
393
- st.success(f"Vídeo reconstruït i desat a: {output_path}")
394
- st.info("Pots visualitzar-lo activant la casella 'Afegir audiodescripció' i seleccionant el nou fitxer si cal.")
395
- else:
396
- st.error(f"Error en la reconstrucció del vídeo: {response.get('error', 'Desconegut')}")
397
- else:
398
- st.warning("No s'ha trobat el fitxer 'une_ad.srt' en aquesta versió.")
399
-
400
-
401
- # --- Columna Derecha (Editor de texto y guardado) ---
402
- with col_txt:
403
- tipus_ad_options = ["narració lliure", "UNE-153010"]
404
- tipus_ad_seleccio = st.selectbox("Fitxer d'audiodescripció a editar:", tipus_ad_options)
405
-
406
- ad_filename = "free_ad.txt" if tipus_ad_seleccio == "narració lliure" else "une_ad.srt"
407
-
408
- # Cargar el contenido del fichero seleccionado
409
- text_content = ""
410
- ad_path = None
411
- if subcarpeta_seleccio:
412
- ad_path = vid_dir / subcarpeta_seleccio / ad_filename
413
- if ad_path.exists():
414
- try:
415
- text_content = ad_path.read_text(encoding="utf-8")
416
- except Exception:
417
- text_content = ad_path.read_text(errors="ignore")
418
- else:
419
- st.info(f"No s'ha trobat el fitxer **{ad_filename}**.")
420
- else:
421
- st.warning("Selecciona una versió per veure els fitxers.")
422
-
423
- # Área de texto para edición
424
- new_text = st.text_area(f"Contingut de {tipus_ad_seleccio}", value=text_content, height=500, key=f"editor_{seleccio}_{subcarpeta_seleccio}_{ad_filename}")
425
-
426
- # Controles de reproducción de narración (selector de voz eliminado)
427
- if st.button("▶️ Reproduir narració", use_container_width=True, disabled=not new_text.strip(), key="play_button_editor"):
428
- with st.spinner("Generant àudio..."):
429
- # Lógica de TTS con el texto del área
430
- pass # Implementación de la llamada a la API TTS
431
-
432
- # Botón de guardado
433
- if st.button("Desar canvis", use_container_width=True, type="primary"):
434
- if ad_path:
435
- try:
436
- ad_path.write_text(new_text, encoding="utf-8")
437
- st.success(f"Fitxer **{ad_filename}** desat correctament.")
438
- st.rerun()
439
- except Exception as e:
440
- st.error(f"No s'ha pogut desar el fitxer: {e}")
441
- else:
442
- st.error("No s'ha seleccionat una ruta de fitxer vàlida per desar.")
443
-
444
-
445
- st.markdown("---")
446
- st.subheader("Avaluació de la qualitat de l'audiodescripció")
447
-
448
- role = st.session_state.user["role"]
449
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
450
- can_rate = permissions["valorar"]
451
- controls_disabled = not can_rate
452
-
453
- c1, c2, c3 = st.columns(3)
454
- with c1:
455
- transcripcio = st.slider("Transcripció", 1, 10, 7, disabled=controls_disabled)
456
- identificacio = st.slider("Identificació de personatges", 1, 10, 7, disabled=controls_disabled)
457
- with c2:
458
- localitzacions = st.slider("Localitzacions", 1, 10, 7, disabled=controls_disabled)
459
- activitats = st.slider("Activitats", 1, 10, 7, disabled=controls_disabled)
460
- with c3:
461
- narracions = st.slider("Narracions", 1, 10, 7, disabled=controls_disabled)
462
- expressivitat = st.slider("Expressivitat", 1, 10, 7, disabled=controls_disabled)
463
-
464
- comments = st.text_area(
465
- "Comentaris (opcional)",
466
- placeholder="Escriu els teus comentaris lliures…",
467
- height=120,
468
- disabled=controls_disabled
469
- )
470
-
471
- if not can_rate:
472
- st.info("El teu rol no permet enviar valoracions.")
473
- else:
474
- if st.button("Enviar valoració", type="primary", use_container_width=True):
475
- try:
476
- from database import add_feedback_ad
477
- add_feedback_ad(
478
- video_name=seleccio,
479
- user_id=st.session_state.user["id"],
480
- transcripcio=transcripcio,
481
- identificacio=identificacio,
482
- localitzacions=localitzacions,
483
- activitats=activitats,
484
- narracions=narracions,
485
- expressivitat=expressivitat,
486
- comments=comments or None
487
- )
488
- st.success("Gràcies! La teva valoració s'ha desat correctament.")
489
- except Exception as e:
490
- st.error(f"S'ha produït un error en desar la valoració: {e}")
491
-
492
 
493
  elif page == "Estadístiques":
494
  require_login(render_login_form)
495
- st.header("Estadístiques")
496
-
497
- from database import get_feedback_ad_stats
498
- stats = get_feedback_ad_stats() # medias por vídeo + avg_global
499
- if not stats:
500
- st.caption("Encara no hi ha valoracions.")
501
- st.stop()
502
-
503
- import pandas as pd
504
- df = pd.DataFrame(stats, columns=stats[0].keys())
505
- ordre = st.radio("Ordre de rànquing", ["Descendent (millors primer)", "Ascendent (pitjors primer)"], horizontal=True)
506
- if ordre.startswith("Asc"):
507
- df = df.sort_values("avg_global", ascending=True)
508
- else:
509
- df = df.sort_values("avg_global", ascending=False)
510
-
511
- st.subheader("Rànquing de vídeos")
512
- st.dataframe(
513
- df[["video_name","n","avg_global","avg_transcripcio","avg_identificacio","avg_localitzacions","avg_activitats","avg_narracions", "avg_expressivitat"]],
514
- use_container_width=True
515
- )
516
-
517
 
518
  elif page == "Validació":
519
  require_login(render_login_form)
520
-
521
- username = st.session_state.user["username"]
522
- role = st.session_state.user["role"]
523
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
524
-
525
- if not permissions["validar"]:
526
- st.warning("⚠️ No tens permisos per accedir a aquesta secció de validació.")
527
- st.stop()
528
-
529
- st.header("🔍 Validació de Vídeos")
530
-
531
- tab_videos, tab_ads = st.tabs(["📹 Validar Vídeos", "🎬 Validar Audiodescripcions"])
532
-
533
- with tab_videos:
534
- st.subheader("📹 Validar Vídeos Pujats")
535
-
536
- candidates = [
537
- runtime_videos,
538
- Path(__file__).resolve().parent / "videos",
539
- Path.cwd() / "videos",
540
- ]
541
- base_dir = None
542
- for c in candidates:
543
- if c.exists():
544
- base_dir = c
545
- break
546
- if base_dir is None:
547
- base_dir = candidates[0]
548
-
549
- if not base_dir.exists():
550
- st.info("📝 No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
551
- st.stop()
552
-
553
- video_folders = []
554
- for folder in sorted(base_dir.iterdir()):
555
- if folder.is_dir() and folder.name != 'completed':
556
- video_files = list(folder.glob("*.mp4")) + list(folder.glob("*.avi")) + list(folder.glob("*.mov"))
557
- if video_files:
558
- mod_time = folder.stat().st_mtime
559
- fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
560
- video_folders.append({
561
- 'name': folder.name,
562
- 'path': str(folder),
563
- 'created_at': fecha,
564
- 'video_files': video_files
565
- })
566
-
567
- if not video_folders:
568
- st.info("📝 No hi ha vídeos pujats pendents de validació.")
569
- else:
570
- opciones_video = [f"{video['name']} - {video['created_at']}" for video in video_folders]
571
- seleccion = st.selectbox(
572
- "Selecciona un vídeo per validar:",
573
- opciones_video,
574
- index=0 if opciones_video else None
575
- )
576
-
577
- if seleccion:
578
- indice = opciones_video.index(seleccion)
579
- video_seleccionat = video_folders[indice]
580
-
581
- col1, col2 = st.columns([2, 1])
582
-
583
- with col1:
584
- st.markdown("### 📹 Informació del Vídeo")
585
- st.markdown(f"**Nom:** {video_seleccionat['name']}")
586
- st.markdown(f"**Data:** {video_seleccionat['created_at']}")
587
- st.markdown(f"**Arxius:** {len(video_seleccionat['video_files'])} vídeos trobats")
588
-
589
- if video_seleccionat['video_files']:
590
- video_path = str(video_seleccionat['video_files'][0])
591
- st.markdown("**Vídeo principal:**")
592
- st.video(video_path)
593
- else:
594
- st.warning("⚠️ No s'han trobat arxius de vídeo.")
595
-
596
- with col2:
597
- st.markdown("### 🔍 Accions de Validació")
598
-
599
- col_btn1, col_btn2 = st.columns(2)
600
-
601
- with col_btn1:
602
- if st.button("✅ Acceptar", type="primary", key=f"accept_video_{video_seleccionat['name']}"):
603
- success = compliance_client.record_validator_decision(
604
- document_id=f"video_{video_seleccionat['name']}",
605
- validator_email=f"{username}@veureu.local",
606
- decision="acceptat",
607
- comments=f"Vídeo validat per {username}"
608
- )
609
- if success:
610
- st.success("✅ Vídeo acceptat i registrat al servei de compliance")
611
- else:
612
- st.error("❌ Error registrant el veredicte")
613
-
614
- with col_btn2:
615
- if st.button("❌ Rebutjar", type="secondary", key=f"reject_video_{video_seleccionat['name']}"):
616
- success = compliance_client.record_validator_decision(
617
- document_id=f"video_{video_seleccionat['name']}",
618
- validator_email=f"{username}@veureu.local",
619
- decision="rebutjat",
620
- comments=f"Vídeo rebutjat per {username}"
621
- )
622
- if success:
623
- st.success("✅ Vídeo rebutjat i registrat al servei de compliance")
624
- else:
625
- st.error("❌ Error registrant el veredicte")
626
-
627
- with tab_ads:
628
- st.subheader("🎬 Validar Audiodescripcions")
629
-
630
- candidates = [
631
- runtime_videos,
632
- Path(__file__).resolve().parent / "videos",
633
- Path.cwd() / "videos",
634
- ]
635
- base_dir = None
636
- for c in candidates:
637
- if c.exists():
638
- base_dir = c
639
- break
640
- if base_dir is None:
641
- base_dir = candidates[0]
642
-
643
- videos_con_ad = []
644
- if base_dir.exists():
645
- for folder in sorted(base_dir.iterdir()):
646
- if folder.is_dir() and folder.name != 'completed':
647
- for subfolder_name in ['MoE', 'Salamandra']:
648
- subfolder = folder / subfolder_name
649
- if subfolder.exists():
650
- ad_files = list(subfolder.glob("*_ad.txt")) + list(subfolder.glob("*_ad.srt"))
651
- if ad_files:
652
- mod_time = folder.stat().st_mtime
653
- fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
654
- videos_con_ad.append({
655
- 'name': folder.name,
656
- 'path': str(folder),
657
- 'created_at': fecha,
658
- 'ad_files': ad_files,
659
- 'ad_folder': str(subfolder)
660
- })
661
-
662
- if not videos_con_ad:
663
- st.info("📝 No hi ha audiodescripcions pendents de validació.")
664
- else:
665
- videos_ad_ordenats = sorted(videos_con_ad, key=lambda x: x['created_at'], reverse=True)
666
- opciones_ad = [f"{video['name']} - {video['created_at']}" for video in videos_ad_ordenats]
667
-
668
- seleccion_ad = st.selectbox(
669
- "Selecciona una audiodescripció per validar:",
670
- opciones_ad,
671
- index=0 if opciones_ad else None
672
- )
673
-
674
- if seleccion_ad:
675
- indice = opciones_ad.index(seleccion_ad)
676
- video_seleccionat = videos_ad_ordenats[indice]
677
-
678
- col1, col2 = st.columns([2, 1])
679
-
680
- with col1:
681
- st.markdown("### 🎬 Informació de l'Audiodescripció")
682
- st.markdown(f"**Vídeo:** {video_seleccionat['name']}")
683
- st.markdown(f"**Data:** {video_seleccionat['created_at']}")
684
- st.markdown(f"**Carpeta:** {Path(video_seleccionat['ad_folder']).name}")
685
- st.markdown(f"**Arxius:** {len(video_seleccionat['ad_files'])} audiodescripcions trobades")
686
-
687
- if video_seleccionat['ad_files']:
688
- ad_path = video_seleccionat['ad_files'][0]
689
- st.markdown(f"#### 📄 Contingut ({ad_path.name}):")
690
- try:
691
- texto = ad_path.read_text(encoding="utf-8")
692
- except Exception:
693
- texto = ad_path.read_text(errors="ignore")
694
- st.text_area("Contingut de l'audiodescripció:", texto, height=300, disabled=True)
695
- else:
696
- st.warning("⚠️ No s'han trobat arxius d'audiodescripció.")
697
-
698
- with col2:
699
- st.markdown("### 🔍 Accions de Validació")
700
-
701
- col_btn1, col_btn2 = st.columns(2)
702
-
703
- with col_btn1:
704
- if st.button("✅ Acceptar", type="primary", key=f"accept_ad_{video_seleccionat['name']}"):
705
- success = compliance_client.record_validator_decision(
706
- document_id=f"ad_{video_seleccionat['name']}",
707
- validator_email=f"{username}@veureu.local",
708
- decision="acceptat",
709
- comments=f"Audiodescripció validada per {username}"
710
- )
711
- if success:
712
- st.success("✅ Audiodescripció acceptada i registrada al servei de compliance")
713
- else:
714
- st.error("❌ Error registrant el veredicte")
715
-
716
- with col_btn2:
717
- if st.button("❌ Rebutjar", type="secondary", key=f"reject_ad_{video_seleccionat['name']}"):
718
- success = compliance_client.record_validator_decision(
719
- document_id=f"ad_{video_seleccionat['name']}",
720
- validator_email=f"{username}@veureu.local",
721
- decision="rebutjat",
722
- comments=f"Audiodescripció rebutjada per {username}"
723
- )
724
- if success:
725
- st.success("✅ Audiodescripció rebutjada i registrada al servei de compliance")
726
- else:
727
- st.error("❌ Error registrant el veredicte")
728
-
729
- st.markdown("---")
730
- st.markdown("### ℹ️ Informació del Procés de Validació")
731
- st.markdown(
732
- """
733
- - **Tots els veredictes** es registren al servei de compliance per garantir la traçabilitat
734
- - **Cada validació** inclou veredicte, nom del vídeo i validador responsable
735
- - **Els registres** compleixen amb la normativa AI Act i GDPR
736
- """
737
- )
 
1
  import os
 
 
2
  import yaml
3
  import shutil
 
4
  from pathlib import Path
 
 
5
  try:
6
  import tomllib
7
  except ModuleNotFoundError: # Py<3.11
8
  import tomli as tomllib
9
  import streamlit as st
10
 
11
+ from database import set_db_path, init_schema
12
  from api_client import APIClient
13
+ from utils import ensure_dirs
14
  from auth import initialize_auth_system, render_login_form, render_sidebar, require_login
15
  from mobile_verification import render_mobile_verification_screen, get_user_permissions
16
  from compliance_client import compliance_client
17
+ from pages.process_video import render_process_video_page
18
+ from pages.analyze_transcriptions import render_analyze_transcriptions_page
19
+ from pages.statistics import render_statistics_page
20
+ from pages.validation import render_validation_page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  # -- Move DB ---
 
94
  if st.session_state.user and 'sms_verified' not in st.session_state:
95
  st.session_state.sms_verified = None
96
 
97
+ permissions = None
98
  if st.session_state.user:
99
  username = st.session_state.user['username']
100
  role = st.session_state.user['role']
 
114
  if page == "Processar vídeo nou":
115
  require_login(render_login_form)
116
 
 
117
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
118
  if not permissions["procesar_videos"]:
119
  st.error("No tens permisos per processar nous vídeos. Verifica el teu mòbil per obtenir accés complet.")
120
  st.stop()
121
 
122
+ render_process_video_page()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  elif page == "Analitzar video-transcripcions":
125
  require_login(render_login_form)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
127
+ render_analyze_transcriptions_page(api, permissions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  elif page == "Estadístiques":
130
  require_login(render_login_form)
131
+ render_statistics_page()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  elif page == "Validació":
134
  require_login(render_login_form)
 
 
 
135
  permissions = get_user_permissions(role, st.session_state.get('sms_verified'))
136
+ render_validation_page(compliance_client, runtime_videos, permissions, username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pages/__.keep ADDED
File without changes
pages/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Modular page renderers for the Veureu Streamlit app."""
pages/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}")
pages/process_video.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, falling back to ffmpeg when needed."""
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
+ return 0.0
45
+
46
+
47
+ def _transcode_video(input_path: str, output_path: str, max_duration: int | None = None) -> None:
48
+ cmd = ["ffmpeg", "-y", "-i", input_path]
49
+ if max_duration is not None:
50
+ cmd += ["-t", str(max_duration)]
51
+ cmd += [
52
+ "-c:v",
53
+ "libx264",
54
+ "-preset",
55
+ "veryfast",
56
+ "-crf",
57
+ "23",
58
+ "-c:a",
59
+ "aac",
60
+ "-movflags",
61
+ "+faststart",
62
+ output_path,
63
+ ]
64
+ result = subprocess.run(cmd, capture_output=True, text=True)
65
+ if result.returncode != 0:
66
+ raise RuntimeError(result.stderr.strip() or "ffmpeg failed")
67
+
68
+
69
+ def render_process_video_page() -> None:
70
+ st.header("Processar un nou clip de vídeo")
71
+
72
+ # Inicializar el estado de la página si no existe
73
+ if "video_uploaded" not in st.session_state:
74
+ st.session_state.video_uploaded = None
75
+ if "characters_detected" not in st.session_state:
76
+ st.session_state.characters_detected = None
77
+ if "characters_saved" not in st.session_state:
78
+ st.session_state.characters_saved = False
79
+
80
+ # --- 1. Subida del vídeo ---
81
+ MAX_SIZE_MB = 20
82
+ MAX_DURATION_S = 240 # 4 minutos
83
+
84
+ uploaded_file = st.file_uploader(
85
+ "Puja un clip de vídeo (MP4, < 20MB, < 4 minuts)",
86
+ type=["mp4"],
87
+ key="video_uploader",
88
+ )
89
+
90
+ if uploaded_file is not None:
91
+ # Resetear el estado si se sube un nuevo archivo
92
+ if st.session_state.video_uploaded is None or uploaded_file.name != st.session_state.video_uploaded.get(
93
+ "original_name"
94
+ ):
95
+ st.session_state.video_uploaded = {"original_name": uploaded_file.name, "status": "validating"}
96
+ st.session_state.characters_detected = None
97
+ st.session_state.characters_saved = False
98
+
99
+ if st.session_state.video_uploaded["status"] == "validating":
100
+ is_valid = True
101
+ if uploaded_file.size > MAX_SIZE_MB * 1024 * 1024:
102
+ st.error(f"El vídeo supera el límit de {MAX_SIZE_MB}MB.")
103
+ is_valid = False
104
+
105
+ if is_valid:
106
+ with st.spinner("Processant el vídeo..."):
107
+ temp_path = Path("temp_video.mp4")
108
+ with temp_path.open("wb") as f:
109
+ f.write(uploaded_file.getbuffer())
110
+
111
+ was_truncated = False
112
+ final_video_path = None
113
+ try:
114
+ duration = _get_video_duration(str(temp_path))
115
+ if not duration:
116
+ st.error("No s'ha pogut obtenir la durada del vídeo.")
117
+ is_valid = False
118
+
119
+ if is_valid:
120
+ if duration > MAX_DURATION_S:
121
+ was_truncated = True
122
+
123
+ video_name = Path(uploaded_file.name).stem
124
+ video_dir = Path("/tmp/data/videos") / video_name
125
+ video_dir.mkdir(parents=True, exist_ok=True)
126
+ final_video_path = video_dir / f"{video_name}.mp4"
127
+
128
+ try:
129
+ _transcode_video(
130
+ str(temp_path),
131
+ str(final_video_path),
132
+ MAX_DURATION_S if was_truncated else None,
133
+ )
134
+ except RuntimeError as exc:
135
+ st.error(f"No s'ha pogut processar el vídeo: {exc}")
136
+ is_valid = False
137
+
138
+ if is_valid and final_video_path is not None:
139
+ st.session_state.video_uploaded.update(
140
+ {
141
+ "status": "processed",
142
+ "path": str(final_video_path),
143
+ "was_truncated": was_truncated,
144
+ }
145
+ )
146
+ st.rerun()
147
+ finally:
148
+ if temp_path.exists():
149
+ temp_path.unlink()
150
+
151
+ if st.session_state.video_uploaded and st.session_state.video_uploaded["status"] == "processed":
152
+ st.success(f"Vídeo '{st.session_state.video_uploaded['original_name']}' pujat i processat correctament.")
153
+ if st.session_state.video_uploaded["was_truncated"]:
154
+ st.warning("El vídeo s'ha truncat a 4 minuts.")
155
+
156
+ st.markdown("---")
157
+ col1, col2 = st.columns([1, 3])
158
+ with col1:
159
+ detect_button_disabled = st.session_state.video_uploaded is None
160
+ if st.button("Detectar Personatges", disabled=detect_button_disabled):
161
+ with st.spinner("Detectant personatges..."):
162
+ st.session_state.characters_detected = [
163
+ {
164
+ "id": "char1",
165
+ "image_path": "init_data/placeholder.png",
166
+ "description": "Dona amb cabell ros i ulleres",
167
+ },
168
+ {
169
+ "id": "char2",
170
+ "image_path": "init_data/placeholder.png",
171
+ "description": "Home amb barba i barret",
172
+ },
173
+ ]
174
+ st.session_state.characters_saved = False
175
+
176
+ if st.session_state.characters_detected:
177
+ st.subheader("Personatges detectats")
178
+ for char in st.session_state.characters_detected:
179
+ with st.form(key=f"form_{char['id']}"):
180
+ col1, col2 = st.columns(2)
181
+ with col1:
182
+ st.image(char["image_path"], width=150)
183
+ with col2:
184
+ st.caption(char["description"])
185
+ st.text_input("Nom del personatge", key=f"name_{char['id']}")
186
+ st.form_submit_button("Cercar")
187
+
188
+ st.markdown("---_**")
189
+
190
+ col1, col2, col3 = st.columns([1, 1, 2])
191
+ with col1:
192
+ if st.button("Desar", type="primary"):
193
+ st.session_state.characters_saved = True
194
+ st.success("Personatges desats correctament.")
195
+
196
+ with col2:
197
+ if st.session_state.characters_saved:
198
+ st.button("Generar Audiodescripció")
pages/statistics.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Estadístiques" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ import streamlit as st
7
+
8
+ from database import get_feedback_ad_stats
9
+
10
+
11
+ def render_statistics_page() -> None:
12
+ st.header("Estadístiques")
13
+
14
+ stats = get_feedback_ad_stats()
15
+ if not stats:
16
+ st.caption("Encara no hi ha valoracions.")
17
+ st.stop()
18
+
19
+ df = pd.DataFrame(stats, columns=stats[0].keys())
20
+ ordre = st.radio(
21
+ "Ordre de rànquing",
22
+ ["Descendent (millors primer)", "Ascendent (pitjors primer)"],
23
+ horizontal=True,
24
+ )
25
+ if ordre.startswith("Asc"):
26
+ df = df.sort_values("avg_global", ascending=True)
27
+ else:
28
+ df = df.sort_values("avg_global", ascending=False)
29
+
30
+ st.subheader("Rànquing de vídeos")
31
+ st.dataframe(
32
+ df[
33
+ [
34
+ "video_name",
35
+ "n",
36
+ "avg_global",
37
+ "avg_transcripcio",
38
+ "avg_identificacio",
39
+ "avg_localitzacions",
40
+ "avg_activitats",
41
+ "avg_narracions",
42
+ "avg_expressivitat",
43
+ ]
44
+ ],
45
+ use_container_width=True,
46
+ )
pages/validation.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI logic for the "Validació" page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Dict
8
+
9
+ import streamlit as st
10
+
11
+
12
+ def _build_candidates(runtime_videos: Path) -> Path:
13
+ candidates = [
14
+ runtime_videos,
15
+ Path(__file__).resolve().parent.parent / "videos",
16
+ Path.cwd() / "videos",
17
+ ]
18
+ for candidate in candidates:
19
+ if candidate.exists():
20
+ return candidate
21
+ return candidates[0]
22
+
23
+
24
+ def render_validation_page(
25
+ compliance_client,
26
+ runtime_videos: Path,
27
+ permissions: Dict[str, bool],
28
+ username: str,
29
+ ) -> None:
30
+ if not permissions.get("validar", False):
31
+ st.warning("⚠️ No tens permisos per accedir a aquesta secció de validació.")
32
+ st.stop()
33
+
34
+ st.header("🔍 Validació de Vídeos")
35
+
36
+ tab_videos, tab_ads = st.tabs(["📹 Validar Vídeos", "🎬 Validar Audiodescripcions"])
37
+
38
+ base_dir = _build_candidates(runtime_videos)
39
+ if not base_dir.exists():
40
+ st.info("📝 No s'ha trobat la carpeta **videos**. Crea-la i afegeix-hi subcarpetes amb els teus vídeos.")
41
+ st.stop()
42
+
43
+ with tab_videos:
44
+ st.subheader("📹 Validar Vídeos Pujats")
45
+
46
+ video_folders = []
47
+ for folder in sorted(base_dir.iterdir()):
48
+ if folder.is_dir() and folder.name != "completed":
49
+ video_files = list(folder.glob("*.mp4")) + list(folder.glob("*.avi")) + list(folder.glob("*.mov"))
50
+ if video_files:
51
+ mod_time = folder.stat().st_mtime
52
+ fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
53
+ video_folders.append(
54
+ {
55
+ "name": folder.name,
56
+ "path": str(folder),
57
+ "created_at": fecha,
58
+ "video_files": video_files,
59
+ }
60
+ )
61
+
62
+ if not video_folders:
63
+ st.info("📝 No hi ha vídeos pujats pendents de validació.")
64
+ else:
65
+ opciones_video = [f"{video['name']} - {video['created_at']}" for video in video_folders]
66
+ seleccion = st.selectbox(
67
+ "Selecciona un vídeo per validar:",
68
+ opciones_video,
69
+ index=0 if opciones_video else None,
70
+ )
71
+
72
+ if seleccion:
73
+ indice = opciones_video.index(seleccion)
74
+ video_seleccionat = video_folders[indice]
75
+
76
+ col1, col2 = st.columns([2, 1])
77
+
78
+ with col1:
79
+ st.markdown("### 📹 Informació del Vídeo")
80
+ st.markdown(f"**Nom:** {video_seleccionat['name']}")
81
+ st.markdown(f"**Data:** {video_seleccionat['created_at']}")
82
+ st.markdown(f"**Arxius:** {len(video_seleccionat['video_files'])} vídeos trobats")
83
+
84
+ if video_seleccionat["video_files"]:
85
+ video_path = str(video_seleccionat["video_files"][0])
86
+ st.markdown("**Vídeo principal:**")
87
+ st.video(video_path)
88
+ else:
89
+ st.warning("⚠️ No s'han trobat arxius de vídeo.")
90
+
91
+ with col2:
92
+ st.markdown("### 🔍 Accions de Validació")
93
+
94
+ col_btn1, col_btn2 = st.columns(2)
95
+
96
+ with col_btn1:
97
+ if st.button("✅ Acceptar", type="primary", key=f"accept_video_{video_seleccionat['name']}"):
98
+ success = compliance_client.record_validator_decision(
99
+ document_id=f"video_{video_seleccionat['name']}",
100
+ validator_email=f"{username}@veureu.local",
101
+ decision="acceptat",
102
+ comments=f"Vídeo validat per {username}",
103
+ )
104
+ if success:
105
+ st.success("✅ Vídeo acceptat i registrat al servei de compliance")
106
+ else:
107
+ st.error("❌ Error registrant el veredicte")
108
+
109
+ with col_btn2:
110
+ if st.button("❌ Rebutjar", type="secondary", key=f"reject_video_{video_seleccionat['name']}" ):
111
+ success = compliance_client.record_validator_decision(
112
+ document_id=f"video_{video_seleccionat['name']}",
113
+ validator_email=f"{username}@veureu.local",
114
+ decision="rebutjat",
115
+ comments=f"Vídeo rebutjat per {username}",
116
+ )
117
+ if success:
118
+ st.success("✅ Vídeo rebutjat i registrat al servei de compliance")
119
+ else:
120
+ st.error("❌ Error registrant el veredicte")
121
+
122
+ with tab_ads:
123
+ st.subheader("🎬 Validar Audiodescripcions")
124
+
125
+ videos_con_ad = []
126
+ if base_dir.exists():
127
+ for folder in sorted(base_dir.iterdir()):
128
+ if folder.is_dir() and folder.name != "completed":
129
+ for subfolder_name in ["MoE", "Salamandra"]:
130
+ subfolder = folder / subfolder_name
131
+ if subfolder.exists():
132
+ ad_files = list(subfolder.glob("*_ad.txt")) + list(subfolder.glob("*_ad.srt"))
133
+ if ad_files:
134
+ mod_time = folder.stat().st_mtime
135
+ fecha = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M")
136
+ videos_con_ad.append(
137
+ {
138
+ "name": folder.name,
139
+ "path": str(folder),
140
+ "created_at": fecha,
141
+ "ad_files": ad_files,
142
+ "ad_folder": str(subfolder),
143
+ }
144
+ )
145
+
146
+ if not videos_con_ad:
147
+ st.info("📝 No hi ha audiodescripcions pendents de validació.")
148
+ else:
149
+ videos_ad_ordenats = sorted(videos_con_ad, key=lambda x: x["created_at"], reverse=True)
150
+ opciones_ad = [f"{video['name']} - {video['created_at']}" for video in videos_ad_ordenats]
151
+
152
+ seleccion_ad = st.selectbox(
153
+ "Selecciona una audiodescripció per validar:",
154
+ opciones_ad,
155
+ index=0 if opciones_ad else None,
156
+ )
157
+
158
+ if seleccion_ad:
159
+ indice = opciones_ad.index(seleccion_ad)
160
+ video_seleccionat = videos_ad_ordenats[indice]
161
+
162
+ col1, col2 = st.columns([2, 1])
163
+
164
+ with col1:
165
+ st.markdown("### 🎬 Informació de l'Audiodescripció")
166
+ st.markdown(f"**Vídeo:** {video_seleccionat['name']}")
167
+ st.markdown(f"**Data:** {video_seleccionat['created_at']}")
168
+ st.markdown(f"**Carpeta:** {Path(video_seleccionat['ad_folder']).name}")
169
+ st.markdown(f"**Arxius:** {len(video_seleccionat['ad_files'])} audiodescripcions trobades")
170
+
171
+ if video_seleccionat["ad_files"]:
172
+ ad_path = video_seleccionat["ad_files"][0]
173
+ st.markdown(f"#### 📄 Contingut ({ad_path.name}):")
174
+ try:
175
+ texto = ad_path.read_text(encoding="utf-8")
176
+ except Exception:
177
+ texto = ad_path.read_text(errors="ignore")
178
+ st.text_area("Contingut de l'audiodescripció:", texto, height=300, disabled=True)
179
+ else:
180
+ st.warning("⚠️ No s'han trobat arxius d'audiodescripció.")
181
+
182
+ with col2:
183
+ st.markdown("### 🔍 Accions de Validació")
184
+
185
+ col_btn1, col_btn2 = st.columns(2)
186
+
187
+ with col_btn1:
188
+ if st.button("✅ Acceptar", type="primary", key=f"accept_ad_{video_seleccionat['name']}"):
189
+ success = compliance_client.record_validator_decision(
190
+ document_id=f"ad_{video_seleccionat['name']}",
191
+ validator_email=f"{username}@veureu.local",
192
+ decision="acceptat",
193
+ comments=f"Audiodescripció validada per {username}",
194
+ )
195
+ if success:
196
+ st.success("✅ Audiodescripció acceptada i registrada al servei de compliance")
197
+ else:
198
+ st.error("❌ Error registrant el veredicte")
199
+
200
+ with col_btn2:
201
+ if st.button("❌ Rebutjar", type="secondary", key=f"reject_ad_{video_seleccionat['name']}" ):
202
+ success = compliance_client.record_validator_decision(
203
+ document_id=f"ad_{video_seleccionat['name']}",
204
+ validator_email=f"{username}@veureu.local",
205
+ decision="rebutjat",
206
+ comments=f"Audiodescripció rebutjada per {username}",
207
+ )
208
+ if success:
209
+ st.success("✅ Audiodescripció rebutjada i registrada al servei de compliance")
210
+ else:
211
+ st.error("❌ Error registrant el veredicte")
212
+
213
+ st.markdown("---")
214
+ st.markdown("### ℹ️ Informació del Procés de Validació")
215
+ st.markdown(
216
+ """
217
+ - **Tots els veredictes** es registren al servei de compliance per garantir la traçabilitat
218
+ - **Cada validació** inclou veredicte, nom del vídeo i validador responsable
219
+ - **Els registres** compleixen amb la normativa AI Act i GDPR
220
+ """
221
+ )