FABLESLIP commited on
Commit
0608bab
·
verified ·
1 Parent(s): d86cc4e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +801 -274
app.py CHANGED
@@ -1,17 +1,13 @@
1
- # app.py — Video Editor API (v0.5.9 + warmup/lazy models)
2
- # Ajouts:
3
- # - /warmup/start (séquentiel, retry, logs, progress)
4
- # - /warmup/status, /warmup/cancel
5
- # - /models/ensure (lazy prefetch dun repo HF unique)
6
- # - /models/status (liste des caches)
7
- #
8
- # NB: UI et routes existantes conservées à l’identique. Aucun chargement de modèle au boot.
9
-
10
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
11
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
12
  from fastapi.staticfiles import StaticFiles
13
  from pathlib import Path
14
- from typing import Optional, Dict, Any, List
15
  import uuid, shutil, cv2, json, time, urllib.parse, sys
16
  import threading
17
  import subprocess
@@ -19,26 +15,19 @@ import shutil as _shutil
19
  # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------
20
  import os
21
  import httpx
22
- from huggingface_hub import snapshot_download # <-- AJOUT
23
 
 
24
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
25
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
26
-
 
27
  _backend_url_cache = {"url": None, "ts": 0.0}
 
28
  def get_backend_base() -> str:
29
- """
30
- Renvoie l'URL du backend.
31
- - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
32
- l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
33
- - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
34
- """
35
  try:
36
  if POINTER_URL:
37
  now = time.time()
38
- need_refresh = (
39
- not _backend_url_cache["url"] or
40
- now - _backend_url_cache["ts"] > 30
41
- )
42
  if need_refresh:
43
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
44
  url = (r.text or "").strip()
@@ -52,21 +41,11 @@ def get_backend_base() -> str:
52
  except Exception:
53
  return FALLBACK_BASE
54
 
55
- # ---------------------------------------------------------------------------
56
- print("[BOOT] Video Editor API starting…")
57
- print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
58
- print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
59
-
60
  app = FastAPI(title="Video Editor API", version="0.5.9")
61
-
62
  DATA_DIR = Path("/app/data")
63
  THUMB_DIR = DATA_DIR / "_thumbs"
64
  MASK_DIR = DATA_DIR / "_masks"
65
- # ---- AJOUT: dossiers pour warm-up / état / modèles
66
- STATE_DIR = DATA_DIR / "_state"
67
- MODELS_DIR = DATA_DIR / "_models"
68
-
69
- for p in (DATA_DIR, THUMB_DIR, MASK_DIR, STATE_DIR, MODELS_DIR):
70
  p.mkdir(parents=True, exist_ok=True)
71
 
72
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
@@ -90,7 +69,6 @@ async def proxy_all(full_path: str, request: Request):
90
  "te","trailers","upgrade"}
91
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
92
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
93
-
94
  # -------------------------------------------------------------------------------
95
  # Global progress dict (vid_stem -> {percent, logs, done})
96
  progress_data: Dict[str, Dict[str, Any]] = {}
@@ -106,7 +84,6 @@ def _has_ffmpeg() -> bool:
106
  return _shutil.which("ffmpeg") is not None
107
 
108
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
109
- # Utilisation en subprocess (pas shell), on échappe la virgule.
110
  return f"scale=min(iw\\,{max_w}):-2"
111
 
112
  def _meta(video: Path):
@@ -123,10 +100,6 @@ def _meta(video: Path):
123
  return {"frames": frames, "fps": fps, "w": w, "h": h}
124
 
125
  def _frame_jpg(video: Path, idx: int) -> Path:
126
- """
127
- Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
128
- Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
129
- """
130
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
131
  if out.exists():
132
  return out
@@ -148,7 +121,6 @@ def _frame_jpg(video: Path, idx: int) -> Path:
148
  return out
149
  except subprocess.CalledProcessError as e:
150
  print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
151
-
152
  cap = cv2.VideoCapture(str(video))
153
  if not cap.isOpened():
154
  print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
@@ -165,7 +137,6 @@ def _frame_jpg(video: Path, idx: int) -> Path:
165
  if not ok or img is None:
166
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
167
  raise HTTPException(500, "Impossible de lire la frame demandée.")
168
- # Redimension (≈320 px)
169
  h, w = img.shape[:2]
170
  if w > 320:
171
  new_w = 320
@@ -205,11 +176,6 @@ def _save_masks(vid: str, data: Dict[str, Any]):
205
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
206
 
207
  def _gen_thumbs_background(video: Path, vid_stem: str):
208
- """
209
- Génère toutes les vignettes en arrière-plan :
210
- - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
211
- - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
212
- """
213
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
214
  try:
215
  m = _meta(video)
@@ -222,10 +188,8 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
222
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
223
  progress_data[vid_stem]['done'] = True
224
  return
225
- # Nettoyer d’anciennes thumbs du même stem
226
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
227
  f.unlink(missing_ok=True)
228
-
229
  if _has_ffmpeg():
230
  out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
231
  cmd = [
@@ -267,7 +231,6 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
267
  if not ok or img is None:
268
  break
269
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
270
- # Redimension léger (≈320 px de large)
271
  h, w = img.shape[:2]
272
  if w > 320:
273
  new_w = 320
@@ -289,13 +252,13 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
289
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
290
  progress_data[vid_stem]['done'] = True
291
 
292
- # ---------- API (existantes) ----------
293
  @app.get("/", tags=["meta"])
294
  def root():
295
  return {
296
  "ok": True,
297
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui",
298
- "/warmup/start", "/warmup/status", "/warmup/cancel", "/models/ensure", "/models/status"]
299
  }
300
 
301
  @app.get("/health", tags=["meta"])
@@ -467,238 +430,802 @@ async def delete_mask(payload: Dict[str, Any] = Body(...)):
467
  _save_masks(vid, data)
468
  return {"ok": True}
469
 
470
- # ---------- UI (inchangée) ----------
471
  HTML_TEMPLATE = r"""
472
  <!doctype html>
473
  <html lang="fr"><meta charset="utf-8">
474
  <title>Video Editor</title>
475
  <style>
476
- ... (CONTENU CSS/HTML/Javascript INCHANGÉ – même que ta v0.5.9) ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  </style>
478
- <!-- (Pour éviter une réponse trop longue, ce commentaire indique simplement
479
- que le bloc HTML est celui que tu as posté mot pour mot.
480
- Si tu préfères, je te redonne l’intégralité du HTML sans cette coupe.) -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  </html>
482
  """
483
 
484
- # NOTE: Pour garder la réponse dans une taille raisonnable ici,
485
- # je n’ai pas recopié les ~700 lignes de HTML/JS.
486
- # Dans ton dépôt, conserve EXACTEMENT ton HTML_TEMPLATE d’origine.
487
- # Rien n’a été modifié côté UI.
488
-
489
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
490
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
491
- vid = v or ""
492
- try:
493
- msg = urllib.parse.unquote(msg or "")
494
- except Exception:
495
- pass
496
- html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
497
- return HTMLResponse(content=html)
498
-
499
- # =============================================================================
500
- # WARM-UP / LAZY MODELS (AJOUT)
501
- # =============================================================================
502
-
503
- def _repo_dirname(repo_id: str) -> str:
504
- # Répertoire local (un par dépôt), stable et sans slash
505
- return repo_id.replace("/", "__")
506
-
507
- def _repo_local_dir(repo_id: str) -> Path:
508
- return MODELS_DIR / _repo_dirname(repo_id)
509
-
510
- def _is_repo_cached(repo_id: str) -> bool:
511
- d = _repo_local_dir(repo_id)
512
  try:
513
- return d.exists() and any(d.rglob("*"))
514
  except Exception:
515
- return False
516
-
517
- def _default_repos() -> List[str]:
518
- # Liste par défaut — sûre et publique
519
- env_csv = os.getenv("WARMUP_REPOS", "").strip()
520
- if env_csv:
521
- # CSV -> liste
522
- lst = [x.strip() for x in env_csv.split(",") if x.strip()]
523
- if lst:
524
- return lst
525
- return [
526
- "facebook/sam2-hiera-large",
527
- "runwayml/stable-diffusion-inpainting",
528
- "Kijai/Diffuse-Inpaint-Erase",
529
- # Tu peux ajouter d’autres dépôts ici si besoin.
530
- ]
531
-
532
- # État global du warm-up (thread + cancel)
533
- _warmup_lock = threading.Lock()
534
- _warmup_thread: Optional[threading.Thread] = None
535
- _warmup_cancel = threading.Event()
536
- _warmup_state: Dict[str, Any] = {
537
- "running": False,
538
- "done": False,
539
- "percent": 0,
540
- "i": 0,
541
- "n": 0,
542
- "current": None,
543
- "logs": [],
544
- "repos": [],
545
- "started_at": 0.0,
546
- "finished_at": 0.0,
547
- "error": None,
548
- }
549
-
550
- def _log_wu(msg: str):
551
- _warmup_state["logs"].append(msg)
552
- # limiter la taille des logs en mémoire
553
- if len(_warmup_state["logs"]) > 500:
554
- _warmup_state["logs"] = _warmup_state["logs"][-500:]
555
-
556
- def _set_percent(i: int, n: int):
557
- pct = int((i / n) * 100) if n > 0 else 0
558
- _warmup_state["percent"] = min(100, max(0, pct))
559
-
560
- def _snapshot_prefetch(repo_id: str, local_dir: Path):
561
- # Idempotent : si déjà en cache, HF renvoie quasi-instantanément.
562
- snapshot_download(
563
- repo_id=repo_id,
564
- local_dir=str(local_dir),
565
- local_dir_use_symlinks=True,
566
- resume_download=True,
567
- )
568
-
569
- def _run_warmup(repos: List[str], max_retries: int = 3, continue_on_error: bool = True, base_backoff: float = 2.0):
570
- try:
571
- _warmup_state.update({
572
- "running": True, "done": False, "percent": 0, "error": None,
573
- "i": 0, "n": len(repos), "current": None, "logs": [], "repos": repos,
574
- "started_at": time.time(), "finished_at": 0.0
575
- })
576
- _log_wu(f"Warm-up démarré — {len(repos)} dépôts.")
577
-
578
- for idx, repo in enumerate(repos, start=1):
579
- if _warmup_cancel.is_set():
580
- _log_wu("Annulé par l’utilisateur.")
581
- break
582
- _warmup_state["current"] = repo
583
- _set_percent(idx-1, len(repos))
584
- local_dir = _repo_local_dir(repo)
585
- local_dir.mkdir(parents=True, exist_ok=True)
586
-
587
- if _is_repo_cached(repo):
588
- _log_wu(f"[{idx}/{len(repos)}] {repo} — déjà en cache.")
589
- _warmup_state["i"] = idx
590
- _set_percent(idx, len(repos))
591
- continue
592
-
593
- ok = False
594
- for attempt in range(1, max_retries+1):
595
- if _warmup_cancel.is_set():
596
- break
597
- try:
598
- _log_wu(f"[{idx}/{len(repos)}] {repo} — téléchargement (essai {attempt}/{max_retries})…")
599
- _snapshot_prefetch(repo, local_dir)
600
- _log_wu(f"[{idx}/{len(repos)}] {repo} — OK.")
601
- ok = True
602
- break
603
- except Exception as e:
604
- _log_wu(f"[{idx}/{len(repos)}] {repo} — ÉCHEC : {e}")
605
- if attempt < max_retries:
606
- backoff = base_backoff * attempt
607
- _log_wu(f" ↳ retry dans {backoff:.1f}s…")
608
- time.sleep(backoff)
609
- _warmup_state["i"] = idx
610
- _set_percent(idx, len(repos))
611
- if not ok and not continue_on_error:
612
- _warmup_state["error"] = f"Echec warm-up sur {repo}"
613
- break
614
-
615
- _warmup_state["finished_at"] = time.time()
616
- _warmup_state["done"] = True
617
- _warmup_state["running"] = False
618
- if _warmup_cancel.is_set():
619
- _warmup_state["error"] = _warmup_state.get("error") or "Annulé"
620
- _log_wu("Warm-up terminé (annulé).")
621
- else:
622
- _log_wu("Warm-up terminé.")
623
- except Exception as e:
624
- _warmup_state["error"] = str(e)
625
- _warmup_state["done"] = True
626
- _warmup_state["running"] = False
627
- _log_wu(f"Warm-up: exception non gérée: {e}")
628
- finally:
629
- _warmup_cancel.clear()
630
-
631
- @app.post("/warmup/start", tags=["warmup"])
632
- def warmup_start(payload: Dict[str, Any] = Body(default=None)):
633
- """
634
- Démarre un warm-up séquentiel (thread en arrière-plan).
635
- Body JSON optionnel:
636
- {
637
- "repos": ["facebook/sam2-hiera-large", "..."],
638
- "max_retries": 3,
639
- "continue_on_error": true
640
- }
641
- """
642
- with _warmup_lock:
643
- if _warmup_state.get("running"):
644
- return {"ok": False, "running": True, "msg": "Déjà en cours", "status": _warmup_state}
645
- repos = None
646
- if payload and isinstance(payload, dict):
647
- repos = payload.get("repos")
648
- max_retries = int(payload.get("max_retries") or 3)
649
- continue_on_error = bool(payload.get("continue_on_error") if "continue_on_error" in payload else True)
650
- else:
651
- max_retries = 3
652
- continue_on_error = True
653
- if not repos:
654
- repos = _default_repos()
655
- _warmup_cancel.clear()
656
- t = threading.Thread(target=_run_warmup, args=(repos, max_retries, continue_on_error), daemon=True)
657
- t.start()
658
- global _warmup_thread
659
- _warmup_thread = t
660
- return {"ok": True, "running": True, "status": _warmup_state}
661
-
662
- @app.get("/warmup/status", tags=["warmup"])
663
- def warmup_status():
664
- return _warmup_state
665
-
666
- @app.post("/warmup/cancel", tags=["warmup"])
667
- def warmup_cancel():
668
- if not _warmup_state.get("running"):
669
- return {"ok": False, "msg": "Aucun warm-up en cours"}
670
- _warmup_cancel.set()
671
- return {"ok": True, "msg": "Annulation demandée"}
672
-
673
- @app.post("/models/ensure", tags=["warmup"])
674
- def models_ensure(payload: Dict[str, Any] = Body(...)):
675
- """
676
- Lazy load: assure la présence locale d’un dépôt (idempotent).
677
- Body: {"repo_id":"owner/name"}
678
- """
679
- repo_id = (payload or {}).get("repo_id")
680
- if not repo_id or not isinstance(repo_id, str):
681
- raise HTTPException(400, "repo_id manquant")
682
- d = _repo_local_dir(repo_id)
683
- d.mkdir(parents=True, exist_ok=True)
684
- try:
685
- if _is_repo_cached(repo_id):
686
- return {"ok": True, "repo": repo_id, "status": "already_cached"}
687
- snapshot_download(repo_id=repo_id, local_dir=str(d), local_dir_use_symlinks=True, resume_download=True)
688
- return {"ok": True, "repo": repo_id, "status": "ready"}
689
- except Exception as e:
690
- raise HTTPException(502, f"Prefetch échoué: {e}")
691
-
692
- @app.get("/models/status", tags=["warmup"])
693
- def models_status():
694
- """
695
- Liste les dossiers de modèles présents (côté local).
696
- """
697
- items = []
698
- for p in sorted(MODELS_DIR.glob("*")):
699
- try:
700
- if p.is_dir() and any(p.rglob("*")):
701
- items.append(p.name)
702
- except Exception:
703
- pass
704
- return {"count": len(items), "items": items}
 
1
+ # app.py — Video Editor API (v0.5.9)
2
+ # v0.5.9:
3
+ # - Centrages "Aller à #" / "Frame #" 100% fiables (attend rendu + image chargée)
4
+ # - /mask, /mask/rename, /mask/delete : Body(...) explicite => plus de 422 silencieux
5
+ # - Bouton "Enregistrer masque" : erreurs visibles (alert) si lAPI ne répond pas OK
 
 
 
 
6
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
7
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
8
  from fastapi.staticfiles import StaticFiles
9
  from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
  import uuid, shutil, cv2, json, time, urllib.parse, sys
12
  import threading
13
  import subprocess
 
15
  # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------
16
  import os
17
  import httpx
 
18
 
19
+ print("[BOOT] Video Editor API starting…")
20
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
21
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
22
+ print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
23
+ print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
24
  _backend_url_cache = {"url": None, "ts": 0.0}
25
+
26
  def get_backend_base() -> str:
 
 
 
 
 
 
27
  try:
28
  if POINTER_URL:
29
  now = time.time()
30
+ need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
 
 
 
31
  if need_refresh:
32
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
33
  url = (r.text or "").strip()
 
41
  except Exception:
42
  return FALLBACK_BASE
43
 
 
 
 
 
 
44
  app = FastAPI(title="Video Editor API", version="0.5.9")
 
45
  DATA_DIR = Path("/app/data")
46
  THUMB_DIR = DATA_DIR / "_thumbs"
47
  MASK_DIR = DATA_DIR / "_masks"
48
+ for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
 
 
 
 
49
  p.mkdir(parents=True, exist_ok=True)
50
 
51
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
 
69
  "te","trailers","upgrade"}
70
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
71
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
 
72
  # -------------------------------------------------------------------------------
73
  # Global progress dict (vid_stem -> {percent, logs, done})
74
  progress_data: Dict[str, Dict[str, Any]] = {}
 
84
  return _shutil.which("ffmpeg") is not None
85
 
86
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
 
87
  return f"scale=min(iw\\,{max_w}):-2"
88
 
89
  def _meta(video: Path):
 
100
  return {"frames": frames, "fps": fps, "w": w, "h": h}
101
 
102
  def _frame_jpg(video: Path, idx: int) -> Path:
 
 
 
 
103
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
104
  if out.exists():
105
  return out
 
121
  return out
122
  except subprocess.CalledProcessError as e:
123
  print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
 
124
  cap = cv2.VideoCapture(str(video))
125
  if not cap.isOpened():
126
  print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
 
137
  if not ok or img is None:
138
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
139
  raise HTTPException(500, "Impossible de lire la frame demandée.")
 
140
  h, w = img.shape[:2]
141
  if w > 320:
142
  new_w = 320
 
176
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
177
 
178
  def _gen_thumbs_background(video: Path, vid_stem: str):
 
 
 
 
 
179
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
180
  try:
181
  m = _meta(video)
 
188
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
189
  progress_data[vid_stem]['done'] = True
190
  return
 
191
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
192
  f.unlink(missing_ok=True)
 
193
  if _has_ffmpeg():
194
  out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
195
  cmd = [
 
231
  if not ok or img is None:
232
  break
233
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
 
234
  h, w = img.shape[:2]
235
  if w > 320:
236
  new_w = 320
 
252
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
253
  progress_data[vid_stem]['done'] = True
254
 
255
+ # ---------- API ----------
256
  @app.get("/", tags=["meta"])
257
  def root():
258
  return {
259
  "ok": True,
260
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}",
261
+ "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
262
  }
263
 
264
  @app.get("/health", tags=["meta"])
 
430
  _save_masks(vid, data)
431
  return {"ok": True}
432
 
433
+ # ---------- UI (multi-rects par frame) ----------
434
  HTML_TEMPLATE = r"""
435
  <!doctype html>
436
  <html lang="fr"><meta charset="utf-8">
437
  <title>Video Editor</title>
438
  <style>
439
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
440
+ *{box-sizing:border-box}
441
+ body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
442
+ h1{margin:0 0 8px 0}
443
+ .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
444
+ .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
445
+ .muted{color:var(--muted);font-size:13px}
446
+ .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
447
+ .viewer{max-width:1024px;margin:0 auto; position:relative}
448
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
449
+ video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
450
+ #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
451
+ .timeline-container{margin-top:10px}
452
+ .timeline{position:relative;display:flex;flex-wrap:nowrap;gap:8px;overflow-x:auto;overflow-y:hidden;padding:6px;border:1px solid var(--b);border-radius:10px;-webkit-overflow-scrolling:touch;width:100%}
453
+ .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
454
+ .thumb:hover{transform:scale(1.05)}
455
+ .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
456
+ .thumb img.sel{border-color:var(--active-border)}
457
+ .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
458
+ .thumb.hasmask::after{content:"★";position:absolute;right:6px;top:4px;color:#f5b700;text-shadow:0 0 3px rgba(0,0,0,0.35);font-size:16px}
459
+ .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
460
+ .timeline.filter-masked .thumb:not(.hasmask){display:none}
461
+ .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
462
+ .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
463
+ .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
464
+ .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
465
+ .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
466
+ .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
467
+ ul.clean{list-style:none;padding-left:0;margin:6px 0}
468
+ ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
469
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
470
+ .rename-btn:hover{color:#2563eb}
471
+ .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
472
+ .delete-btn:hover{color:#b91c1c}
473
+ #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
474
+ .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
475
+ .portion-input{width:70px}
476
+ #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
477
+ #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
478
+ #popup {position:fixed;top:20%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:20px;border-radius:8px;box-shadow:0 0 10px rgba(0,0,0,0.2);z-index:1000;display:none;min-width:320px}
479
+ #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
480
+ #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
481
+ #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
482
+ #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
483
+ #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
484
+ .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
485
+ #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
486
+ #inHandle,#outHandle{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);width:6px;background:rgba(37,99,235,.9);border-radius:2px;cursor:ew-resize;display:none;pointer-events:auto}
487
+ .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
488
  </style>
489
+ <h1>🎬 Video Editor</h1>
490
+ <div class="topbar card">
491
+ <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
492
+ <strong>Charger une vidéo :</strong>
493
+ <input type="file" name="file" accept="video/*" required>
494
+ <button class="btn" type="submit">Uploader</button>
495
+ </form>
496
+ <span class="muted" id="msg">__MSG__</span>
497
+ <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
498
+ </div>
499
+ <div class="layout">
500
+ <div>
501
+ <div class="viewer card" id="viewerCard">
502
+ <div class="player-wrap" id="playerWrap">
503
+ <video id="player" controls playsinline poster="/poster/__VID__">
504
+ <source id="vidsrc" src="/data/__VID__" type="video/mp4">
505
+ </video>
506
+ <canvas id="editCanvas"></canvas>
507
+ <div id="hud"></div>
508
+ </div>
509
+ <div class="muted" style="margin-top:6px">
510
+ <label>Frame # <input id="goFrame" type="number" min="1" value="1" style="width:90px"> </label>
511
+ <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
512
+ <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
513
+ <button id="resetFull" class="btn" style="display:none">Retour full</button>
514
+ <span id="posInfo" style="margin-left:10px"></span>
515
+ <span id="status" style="margin-left:10px;color:#2563eb"></span>
516
+ </div>
517
+ </div>
518
+ <div class="card timeline-container">
519
+ <h4 style="margin:2px 0 8px 0">Timeline</h4>
520
+ <div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center">
521
+ <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
522
+ <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
523
+ <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
524
+ <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
525
+ <button id="gotoBtn" class="btn">Aller</button>
526
+ <span class="muted" id="maskedCount"></span>
527
+ </div>
528
+ <div id="timeline" class="timeline"></div>
529
+ <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
530
+ <div id="loading-indicator">Chargement des frames...</div>
531
+ <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
532
+ </div>
533
+ </div>
534
+ <div class="card tools">
535
+ <div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
536
+ <div class="row" style="margin-top:6px">
537
+ <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
538
+ <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
539
+ <button id="btnSave" class="btn" style="display:none" title="Enregistrer tous les masques dessinés sur cette frame">💾 Enregistrer masque</button>
540
+ <button id="btnClear" class="btn" style="display:none" title="Effacer le dernier rectangle de cette frame">🧽 Effacer sélection</button>
541
+ </div>
542
+ <div style="margin-top:10px">
543
+ <div class="muted">Couleur</div>
544
+ <div class="row" id="palette" style="margin-top:6px">
545
+ <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
546
+ <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
547
+ <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
548
+ <div class="swatch" data-c="#f59e0b" style="background:#f59e0b" title="Orange"></div>
549
+ <div class="swatch" data-c="#a21caf" style="background:#a21caf" title="Violet"></div>
550
+ </div>
551
+ </div>
552
+ <div style="margin-top:12px">
553
+ <details open>
554
+ <summary><strong>Masques</strong></summary>
555
+ <div id="maskList" class="muted">—</div>
556
+ <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
557
+ </details>
558
+ <div style="margin-top:6px">
559
+ <strong>Vidéos disponibles</strong>
560
+ <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
561
+ </div>
562
+ </div>
563
+ </div>
564
+ </div>
565
+ <div id="popup">
566
+ <h3>Génération thumbs en cours</h3>
567
+ <div id="popup-progress-bar"><div id="popup-progress-fill"></div></div>
568
+ <div id="popup-logs"></div>
569
+ </div>
570
+ <div id="toast"></div>
571
+ <script>
572
+ const serverVid = "__VID__";
573
+ const serverMsg = "__MSG__";
574
+ document.getElementById('msg').textContent = serverMsg;
575
+
576
+ // Elements
577
+ const statusEl = document.getElementById('status');
578
+ const player = document.getElementById('player');
579
+ const srcEl = document.getElementById('vidsrc');
580
+ const canvas = document.getElementById('editCanvas');
581
+ const ctx = canvas.getContext('2d');
582
+ const modeLabel = document.getElementById('modeLabel');
583
+ const btnEdit = document.getElementById('btnEdit');
584
+ const btnBack = document.getElementById('btnBack');
585
+ const btnSave = document.getElementById('btnSave');
586
+ const btnClear= document.getElementById('btnClear');
587
+ const posInfo = document.getElementById('posInfo');
588
+ const goFrame = document.getElementById('goFrame');
589
+ const palette = document.getElementById('palette');
590
+ const fileList= document.getElementById('fileList');
591
+ const tlBox = document.getElementById('timeline');
592
+ const tlNote = document.getElementById('tlNote');
593
+ const playerWrap = document.getElementById('playerWrap');
594
+ const loadingInd = document.getElementById('loading-indicator');
595
+ const isolerBoucle = document.getElementById('isolerBoucle');
596
+ const resetFull = document.getElementById('resetFull');
597
+ const endPortion = document.getElementById('endPortion');
598
+ const popup = document.getElementById('popup');
599
+ const popupLogs = document.getElementById('popup-logs');
600
+ const tlProgressFill = document.getElementById('tl-progress-fill');
601
+ const popupProgressFill = document.getElementById('popup-progress-fill');
602
+ const btnFollow = document.getElementById('btnFollow');
603
+ const btnFilterMasked = document.getElementById('btnFilterMasked');
604
+ const zoomSlider = document.getElementById('zoomSlider');
605
+ const maskedCount = document.getElementById('maskedCount');
606
+ const hud = document.getElementById('hud');
607
+ const toastWrap = document.getElementById('toast');
608
+ const gotoInput = document.getElementById('gotoInput');
609
+ const gotoBtn = document.getElementById('gotoBtn');
610
+
611
+ // State
612
+ let vidName = serverVid || '';
613
+ function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
614
+ let vidStem = '';
615
+ let bustToken = Date.now();
616
+ let fps = 30, frames = 0;
617
+ let currentIdx = 0;
618
+ let mode = 'view';
619
+
620
+ // ==== Multi-rects ====
621
+ let currentDraw = null; // rect en cours de tracé
622
+ let rectsMap = new Map(); // frame_idx -> Array<rect>
623
+ let color = '#10b981';
624
+
625
+ // autres états inchangés
626
+ let masks = [];
627
+ let maskedSet = new Set();
628
+ let timelineUrls = [];
629
+ let portionStart = null;
630
+ let portionEnd = null;
631
+ let loopInterval = null;
632
+ let chunkSize = 50;
633
+ let timelineStart = 0, timelineEnd = 0;
634
+ let viewRangeStart = 0, viewRangeEnd = 0;
635
+ const scrollThreshold = 100;
636
+ let followMode = false;
637
+ let isPaused = true;
638
+ let thumbEls = new Map();
639
+ let lastCenterMs = 0;
640
+ const CENTER_THROTTLE_MS = 150;
641
+ const PENDING_KEY = 've_pending_masks_v1';
642
+ let maskedOnlyMode = false;
643
+
644
+ // Utils
645
+ function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
646
+ function ensureOverlays(){
647
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
648
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
649
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
650
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
651
+ }
652
+ function playheadEl(){ return document.getElementById('playhead'); }
653
+ function portionBand(){ return document.getElementById('portionBand'); }
654
+ function inHandle(){ return document.getElementById('inHandle'); }
655
+ function outHandle(){ return document.getElementById('outHandle'); }
656
+ function findThumbEl(idx){ return thumbEls.get(idx) || null; }
657
+ function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
658
+ function updateSelectedThumb(){
659
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
660
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
661
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
662
+ }
663
+ function rawCenterThumb(el){
664
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
665
+ }
666
+ async function ensureThumbVisibleCentered(idx){
667
+ for(let k=0; k<40; k++){
668
+ const el = findThumbEl(idx);
669
+ if(el){
670
+ const img = el.querySelector('img');
671
+ if(!img.complete || img.naturalWidth === 0){
672
+ await new Promise(r=>setTimeout(r, 25));
673
+ }else{
674
+ rawCenterThumb(el);
675
+ updatePlayhead();
676
+ return true;
677
+ }
678
+ }else{
679
+ await new Promise(r=>setTimeout(r, 25));
680
+ }
681
+ }
682
+ return false;
683
+ }
684
+ function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
685
+ function updatePlayhead(){
686
+ ensureOverlays();
687
+ const el = findThumbEl(currentIdx);
688
+ const ph = playheadEl();
689
+ if(!el){ ph.style.display='none'; return; }
690
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
691
+ }
692
+ function updatePortionOverlays(){
693
+ ensureOverlays();
694
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
695
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
696
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
697
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
698
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
699
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
700
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
701
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
702
+ }
703
+ function nearestFrameIdxFromClientX(clientX){
704
+ const rect = tlBox.getBoundingClientRect();
705
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
706
+ let bestIdx = currentIdx, bestDist = Infinity;
707
+ for(const [idx, el] of thumbEls.entries()){
708
+ const mid = el.offsetLeft + el.clientWidth/2;
709
+ const d = Math.abs(mid - xIn);
710
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
711
+ }
712
+ return bestIdx;
713
+ }
714
+ function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
715
+ function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
716
+ function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
717
+ async function flushPending(){
718
+ const lst = loadPending(); if(!lst.length) return;
719
+ const kept = [];
720
+ for(const p of lst){
721
+ try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
722
+ catch{ kept.push(p); }
723
+ }
724
+ savePendingList(kept);
725
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
726
+ }
727
+
728
+ // Layout
729
+ function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
730
+ function fitCanvas(){
731
+ const r=player.getBoundingClientRect();
732
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
733
+ canvas.width=Math.round(r.width);
734
+ canvas.height=Math.round(r.height - ctrlH);
735
+ canvas.style.width=r.width+'px';
736
+ canvas.style.height=(r.height - ctrlH)+'px';
737
+ syncTimelineWidth();
738
+ }
739
+ function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
740
+ function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
741
+
742
+ // ==== Multi-rects helpers ====
743
+ function getRectsForFrame(fi){
744
+ let arr = rectsMap.get(fi);
745
+ if(!arr){ arr = []; rectsMap.set(fi, arr); }
746
+ return arr;
747
+ }
748
+ function draw(){
749
+ ctx.clearRect(0,0,canvas.width,canvas.height);
750
+ const arr = getRectsForFrame(currentIdx);
751
+ for(const r of arr){
752
+ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2);
753
+ const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1);
754
+ ctx.strokeStyle=r.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
755
+ ctx.fillStyle=(r.color||color)+'28'; ctx.fillRect(x,y,w,h);
756
+ }
757
+ if(currentDraw){
758
+ const x=Math.min(currentDraw.x1,currentDraw.x2), y=Math.min(currentDraw.y1,currentDraw.y2);
759
+ const w=Math.abs(currentDraw.x2-currentDraw.x1), h=Math.abs(currentDraw.y2-currentDraw.y1);
760
+ ctx.setLineDash([6,4]);
761
+ ctx.strokeStyle=currentDraw.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
762
+ ctx.setLineDash([]);
763
+ }
764
+ }
765
+ function setMode(m){
766
+ mode=m;
767
+ if(m==='edit'){
768
+ player.pause();
769
+ player.controls = false;
770
+ playerWrap.classList.add('edit-mode');
771
+ modeLabel.textContent='Édition';
772
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
773
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
774
+ canvas.style.pointerEvents='auto';
775
+ draw();
776
+ }else{
777
+ player.controls = true;
778
+ playerWrap.classList.remove('edit-mode');
779
+ modeLabel.textContent='Lecture';
780
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
781
+ btnSave.style.display='none'; btnClear.style.display='none';
782
+ canvas.style.pointerEvents='none';
783
+ currentDraw=null; draw();
784
+ }
785
+ }
786
+
787
+ // Dessin multi-rectangles
788
+ let dragging=false, sx=0, sy=0;
789
+ canvas.addEventListener('mousedown',(e)=>{
790
+ if(mode!=='edit' || !vidName) return;
791
+ dragging=true; const r=canvas.getBoundingClientRect();
792
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
793
+ currentDraw={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
794
+ });
795
+ canvas.addEventListener('mousemove',(e)=>{
796
+ if(!dragging) return;
797
+ const r=canvas.getBoundingClientRect();
798
+ currentDraw.x2=e.clientX-r.left; currentDraw.y2=e.clientY-r.top; draw();
799
+ });
800
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{
801
+ if(!dragging) return;
802
+ dragging=false;
803
+ if(currentDraw){
804
+ const arr = getRectsForFrame(currentIdx);
805
+ // ignorer les clics trop petits
806
+ if(Math.abs(currentDraw.x2-currentDraw.x1) > 3 && Math.abs(currentDraw.y2-currentDraw.y1) > 3){
807
+ arr.push({...currentDraw});
808
+ }
809
+ currentDraw=null; draw();
810
+ }
811
+ }));
812
+
813
+ btnClear.onclick=()=>{
814
+ const arr = getRectsForFrame(currentIdx);
815
+ if(arr.length){ arr.pop(); draw(); }
816
+ };
817
+ btnEdit.onclick =()=> setMode('edit');
818
+ btnBack.onclick =()=> setMode('view');
819
+
820
+ // Palette
821
+ palette.querySelectorAll('.swatch').forEach(el=>{
822
+ if(el.dataset.c===color) el.classList.add('sel');
823
+ el.onclick=()=>{
824
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
825
+ el.classList.add('sel'); color=el.dataset.c;
826
+ if(currentDraw){ currentDraw.color=color; draw(); }
827
+ };
828
+ });
829
+
830
+ // === Timeline (identique sauf interactions internes) ===
831
+ async function loadTimelineUrls(){
832
+ timelineUrls = [];
833
+ const stem = vidStem, b = bustToken;
834
+ for(let idx=0; idx<frames; idx++){
835
+ timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`;
836
+ }
837
+ tlProgressFill.style.width='0%';
838
+ }
839
+ async function renderTimeline(centerIdx){
840
+ if(!vidName) return;
841
+ loadingInd.style.display='block';
842
+ if(timelineUrls.length===0) await loadTimelineUrls();
843
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
844
+
845
+ if(maskedOnlyMode){
846
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
847
+ for(const i of idxs){ addThumb(i,'append'); }
848
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
849
+ return;
850
+ }
851
+
852
+ if(portionStart!=null && portionEnd!=null){
853
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
854
+ for(let i=s;i<e;i++){ addThumb(i,'append'); }
855
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
856
+ return;
857
+ }
858
+
859
+ await loadWindow(centerIdx ?? currentIdx);
860
+ loadingInd.style.display='none';
861
+ }
862
+ async function loadWindow(centerIdx){
863
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
864
+ const rngStart = (viewRangeStart ?? 0);
865
+ const rngEnd = (viewRangeEnd ?? frames);
866
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
867
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
868
+ const end = Math.min(rngEnd, start + chunkSize);
869
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
870
+ timelineStart = start; timelineEnd = end;
871
+ setTimeout(async ()=>{
872
+ syncTimelineWidth();
873
+ updateSelectedThumb();
874
+ await ensureThumbVisibleCentered(currentIdx);
875
+ updatePortionOverlays();
876
+ },0);
877
+ }
878
+ function addThumb(idx, place='append'){
879
+ if(thumbEls.has(idx)) return;
880
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
881
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
882
+ const img=new Image(); img.title='frame '+(idx+1);
883
+ img.src=timelineUrls[idx];
884
+ img.onerror = () => {
885
+ const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
886
+ img.onerror = null;
887
+ img.src = fallback;
888
+ img.onload = () => {
889
+ const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
890
+ timelineUrls[idx] = nu;
891
+ img.src = nu;
892
+ img.onload = null;
893
+ };
894
+ };
895
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
896
+ img.onclick=async ()=>{
897
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
898
+ draw();
899
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
900
+ };
901
+ wrap.appendChild(img);
902
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
903
+ wrap.appendChild(label);
904
+ if(place==='append'){ tlBox.appendChild(wrap); }
905
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
906
+ else{ tlBox.appendChild(wrap); }
907
+ thumbEls.set(idx, wrap);
908
+ }
909
+
910
+ tlBox.addEventListener('scroll', ()=>{
911
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
912
+ updatePlayhead(); updatePortionOverlays();
913
+ return;
914
+ }
915
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
916
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
917
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
918
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
919
+ timelineEnd = newEnd;
920
+ }
921
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
922
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
923
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
924
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
925
+ timelineStart = newStart;
926
+ }
927
+ updatePlayhead(); updatePortionOverlays();
928
+ });
929
+
930
+ // Isoler & Boucle (inchangé)
931
+ isolerBoucle.onclick = async ()=>{
932
+ const start = parseInt(goFrame.value || '1',10) - 1;
933
+ const end = parseInt(endPortion.value || '',10);
934
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
935
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
936
+ portionStart = start; portionEnd = end;
937
+ viewRangeStart = start; viewRangeEnd = end;
938
+ player.pause(); isPaused = true;
939
+ currentIdx = start; player.currentTime = idxToSec(start);
940
+ await renderTimeline(currentIdx);
941
+ resetFull.style.display = 'inline-block';
942
+ startLoop(); updatePortionOverlays();
943
+ };
944
+ function startLoop(){
945
+ if(loopInterval) clearInterval(loopInterval);
946
+ if(portionEnd != null){
947
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
948
+ }
949
+ }
950
+ resetFull.onclick = async ()=>{
951
+ portionStart = null; portionEnd = null;
952
+ viewRangeStart = 0; viewRangeEnd = frames;
953
+ goFrame.value = 1; endPortion.value = '';
954
+ player.pause(); isPaused = true;
955
+ await renderTimeline(currentIdx);
956
+ resetFull.style.display='none';
957
+ clearInterval(loopInterval); updatePortionOverlays();
958
+ };
959
+
960
+ // Drag IN/OUT (inchangé)
961
+ function attachHandleDrag(handle, which){
962
+ let draggingH=false;
963
+ function onMove(e){
964
+ if(!draggingH) return;
965
+ const idx = nearestFrameIdxFromClientX(e.clientX);
966
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
967
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
968
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
969
+ updatePortionOverlays();
970
+ }
971
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
972
+ window.addEventListener('mousemove', onMove);
973
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
974
+ }
975
+ ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
976
+
977
+ // Progress popup (inchangé)
978
+ async function showProgress(vidStem){
979
+ popup.style.display = 'block';
980
+ const interval = setInterval(async () => {
981
+ const r = await fetch('/progress/' + vidStem);
982
+ const d = await r.json();
983
+ tlProgressFill.style.width = d.percent + '%';
984
+ popupProgressFill.style.width = d.percent + '%';
985
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
986
+ if(d.done){
987
+ clearInterval(interval);
988
+ popup.style.display = 'none';
989
+ await renderTimeline(currentIdx);
990
+ }
991
+ }, 800);
992
+ }
993
+
994
+ // Meta & boot
995
+ async function loadVideoAndMeta() {
996
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
997
+ vidStem = fileStem(vidName); bustToken = Date.now();
998
+ const bust = Date.now();
999
+ srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1000
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
1001
+ player.load();
1002
+ fitCanvas();
1003
+ statusEl.textContent = 'Chargement vidéo…';
1004
+ try{
1005
+ const r=await fetch('/meta/'+encodeURIComponent(vidName));
1006
+ if(r.ok){
1007
+ const m=await r.json();
1008
+ fps=m.fps||30; frames=m.frames||0;
1009
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
1010
+ viewRangeStart = 0; viewRangeEnd = frames;
1011
+ await loadTimelineUrls();
1012
+ await loadMasks();
1013
+ currentIdx = 0; player.currentTime = 0;
1014
+ await renderTimeline(0);
1015
+ showProgress(vidStem);
1016
+ }else{
1017
+ statusEl.textContent = 'Erreur meta';
1018
+ }
1019
+ }catch(err){
1020
+ statusEl.textContent = 'Erreur réseau meta';
1021
+ }
1022
+ }
1023
+ player.addEventListener('loadedmetadata', async ()=>{
1024
+ fitCanvas();
1025
+ if(!frames || frames<=0){
1026
+ try{ const r=await fetch('/meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
1027
+ }
1028
+ currentIdx=0; goFrame.value=1; draw();
1029
+ });
1030
+ window.addEventListener('resize', ()=>{ fitCanvas(); draw(); });
1031
+ player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1032
+ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1033
+ player.addEventListener('timeupdate', ()=>{
1034
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1035
+ currentIdx=timeToIdx(player.currentTime);
1036
+ if(mode==='edit'){ draw(); }
1037
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
1038
+ if(followMode && !isPaused){
1039
+ const now = Date.now();
1040
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1041
+ }
1042
+ });
1043
+ goFrame.addEventListener('change', async ()=>{
1044
+ if(!vidName) return;
1045
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
1046
+ player.pause(); isPaused = true;
1047
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1048
+ await renderTimeline(currentIdx);
1049
+ draw();
1050
+ await ensureThumbVisibleCentered(currentIdx);
1051
+ });
1052
+
1053
+ // Follow / Filter / Zoom / Goto
1054
+ btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1055
+ btnFilterMasked.onclick = async ()=>{
1056
+ maskedOnlyMode = !maskedOnlyMode;
1057
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1058
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1059
+ await renderTimeline(currentIdx);
1060
+ await ensureThumbVisibleCentered(currentIdx);
1061
+ };
1062
+ zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1063
+ async function gotoFrameNum(){
1064
+ const v = parseInt(gotoInput.value||'',10);
1065
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1066
+ player.pause(); isPaused = true;
1067
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1068
+ goFrame.value = v;
1069
+ await renderTimeline(currentIdx);
1070
+ draw();
1071
+ await ensureThumbVisibleCentered(currentIdx);
1072
+ }
1073
+ gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1074
+ gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1075
+
1076
+ // Drag & drop upload
1077
+ const uploadZone = document.getElementById('uploadForm');
1078
+ uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1079
+ uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1080
+ uploadZone.addEventListener('drop', (e) => {
1081
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1082
+ const file = e.dataTransfer.files[0];
1083
+ if(file && file.type.startsWith('video/')){
1084
+ const fd = new FormData(); fd.append('file', file);
1085
+ fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1086
+ }
1087
+ });
1088
+
1089
+ // Export placeholder
1090
+ document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1091
+
1092
+ // Fichiers & masques (chargement existants => rectsMap)
1093
+ async function loadFiles(){
1094
+ const r=await fetch('/files'); const d=await r.json();
1095
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1096
+ fileList.innerHTML='';
1097
+ d.items.forEach(name=>{
1098
+ const li=document.createElement('li');
1099
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1100
+ delBtn.onclick=async()=>{
1101
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1102
+ await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1103
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1104
+ };
1105
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1106
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1107
+ });
1108
+ }
1109
+ async function loadMasks(){
1110
+ loadingInd.style.display='block';
1111
+ const box=document.getElementById('maskList');
1112
+ const r=await fetch('/mask/'+encodeURIComponent(vidName));
1113
+ const d=await r.json();
1114
+ masks=d.masks||[];
1115
+ maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1116
+
1117
+ // Reconstituer rectsMap depuis les masques existants
1118
+ rectsMap.clear();
1119
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1120
+ masks.forEach(m=>{
1121
+ if(m.shape==='rect'){
1122
+ const [x1,y1,x2,y2] = m.points; // normalisés
1123
+ const rx = {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color||'#10b981'};
1124
+ const arr = getRectsForFrame(parseInt(m.frame_idx||0,10));
1125
+ arr.push(rx);
1126
+ }
1127
+ });
1128
+
1129
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1130
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; draw(); return; }
1131
+ box.innerHTML='';
1132
+ const ul=document.createElement('ul'); ul.className='clean';
1133
+ masks.forEach(m=>{
1134
+ const li=document.createElement('li');
1135
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1136
+ const t=(m.time_s||0).toFixed(2);
1137
+ const col=m.color||'#10b981';
1138
+ const label=m.note||(`frame ${fr}`);
1139
+ li.innerHTML = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${col};margin-right:6px;vertical-align:middle"></span> <strong>${label.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</strong> — #${fr} · t=${t}s`;
1140
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1141
+ renameBtn.onclick=async()=>{
1142
+ const nv=prompt('Nouveau nom du masque :', label);
1143
+ if(nv===null) return;
1144
+ const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1145
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1146
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1147
+ }
1148
+ };
1149
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1150
+ delMaskBtn.onclick=async()=>{
1151
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1152
+ const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1153
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1154
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1155
+ }
1156
+ };
1157
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1158
+ });
1159
+ box.appendChild(ul);
1160
+ loadingInd.style.display='none';
1161
+ draw();
1162
+ }
1163
+
1164
+ // Save masks (tous les rectangles de la frame courante)
1165
+ btnSave.onclick = async ()=>{
1166
+ const arr = getRectsForFrame(currentIdx);
1167
+ if(currentDraw){
1168
+ // finaliser le tracé en cours
1169
+ if(Math.abs(currentDraw.x2-currentDraw.x1) > 3 && Math.abs(currentDraw.y2-currentDraw.y1) > 3){
1170
+ arr.push({...currentDraw});
1171
+ }
1172
+ currentDraw=null;
1173
+ }
1174
+ if(!arr.length){ alert('Aucun rectangle sur cette frame.'); return; }
1175
+ const defaultName = `frame ${currentIdx+1}`;
1176
+ const baseNote = (prompt('Nom de base (optionnel) :', defaultName) || defaultName).trim();
1177
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1178
+
1179
+ // Sauvegarder séquentiellement pour éviter les erreurs réseau cumulées
1180
+ for(let i=0;i<arr.length;i++){
1181
+ const r = arr[i];
1182
+ const x=Math.min(r.x1,r.x2)/normW;
1183
+ const y=Math.min(r.y1,r.y2)/normH;
1184
+ const w=Math.abs(r.x2-r.x1)/normW;
1185
+ const h=Math.abs(r.y2-r.y1)/normH;
1186
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:r.color||color,note:`${baseNote} #${i+1}`};
1187
+ addPending(payload);
1188
+ try{
1189
+ const res = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1190
+ if(!res.ok){
1191
+ const txt = await res.text();
1192
+ alert('Échec enregistrement masque: ' + res.status + ' ' + txt);
1193
+ break;
1194
+ }
1195
+ }catch(e){
1196
+ alert('Erreur réseau lors de l’enregistrement.');
1197
+ break;
1198
+ }
1199
+ }
1200
+ // Nettoyage des pendings naïf (optionnel)
1201
+ savePendingList([]);
1202
+ await loadMasks(); await renderTimeline(currentIdx);
1203
+ showToast('Masques enregistrés ✅');
1204
+ };
1205
+
1206
+ // Boot
1207
+ async function boot(){
1208
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1209
+ await loadFiles();
1210
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1211
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1212
+ }
1213
+ boot();
1214
+
1215
+ // Masquer les contrôles natifs en mode édition
1216
+ const style = document.createElement('style');
1217
+ style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1218
+ document.head.appendChild(style);
1219
+ </script>
1220
  </html>
1221
  """
1222
 
 
 
 
 
 
1223
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1224
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1225
+ vid = v or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1226
  try:
1227
+ msg = urllib.parse.unquote(msg or "")
1228
  except Exception:
1229
+ pass
1230
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1231
+ return HTMLResponse(content=html)