FABLESLIP commited on
Commit
87d51bb
·
verified ·
1 Parent(s): d9a75ff

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +796 -394
app.py CHANGED
@@ -1,18 +1,9 @@
1
- # app.py — Video Editor API (v0.8.3)
2
- #
3
- # Changements clés v0.8.3 par rapport à v0.8.2/v0.5.9 :
4
- # - **Multi-rectangles par frame** côté UI: les rectangles enregistrés ne disparaissent plus.
5
- # rectMap: Map[int, List[RectNorm]] (coordonnées normalisées 0..1).
6
- # Le dessin superpose tous les masques enregistrés sur la frame courante + le brouillon en cours.
7
- # • Après un save, le brouillon est vidé mais les masques existants restent affichés.
8
- # - **Sauvegarde de masques robuste**: verrou global + écriture atomique (tmp + os.replace) pour éviter JSON corrompu
9
- # en cas de double-clic/requests concurrentes. Retour d'erreur JSON propre au lieu d'une 500 HTML générique.
10
- # - **Warm-up modèle**: déjà OK chez toi; ici on garde la version robuste (heartbeat, logs, poursuite en cas d'échec).
11
- # • Pas d'échec si HfHubHTTPError indisponible: fallback sur Exception.
12
- # • /warmup/clear pour purger les logs depuis l'UI.
13
- # - **Aller à #** centré pixel-perfect (conservé) + portion 100% fiable (début inclus, fin exclusive).
14
- #
15
- # NB: Ce fichier est autonome (FastAPI + UI HTML/JS).
16
 
17
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
18
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
@@ -28,7 +19,7 @@ import httpx
28
 
29
  print("[BOOT] Video Editor API starting…")
30
 
31
- # ----------------------- POINTEUR BACKEND (inchangé) ------------------------
32
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
33
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
34
  _backend_url_cache = {"url": None, "ts": 0.0}
@@ -54,154 +45,19 @@ def get_backend_base() -> str:
54
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
55
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
56
 
57
- app = FastAPI(title="Video Editor API", version="0.8.3")
58
 
 
59
  DATA_DIR = Path("/app/data")
60
  THUMB_DIR = DATA_DIR / "_thumbs"
61
  MASK_DIR = DATA_DIR / "_masks"
62
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
63
  p.mkdir(parents=True, exist_ok=True)
64
 
65
- # Verrou pour écriture JSON des masques (anti course)
66
- _mask_io_lock = threading.Lock()
67
-
68
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
69
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
70
 
71
- # ----------------------------- WARM-UP STATE --------------------------------
72
- _warmup_state = {
73
- "status": "idle", # idle|running|done|error
74
- "current": None, # repo_id en cours
75
- "done": [], # repos OK
76
- "errors": [], # tuples (repo, str(err))
77
- "logs": [], # dernières lignes
78
- "started_at": None,
79
- "ended_at": None,
80
- }
81
-
82
- _warmup_lock = threading.Lock()
83
-
84
-
85
- def _warm_log(msg: str):
86
- with _warmup_lock:
87
- ts = time.strftime("%H:%M:%S")
88
- _warmup_state["logs"].append(f"[{ts}] {msg}")
89
- # Conserver les 500 dernières lignes max
90
- if len(_warmup_state["logs"]) > 500:
91
- _warmup_state["logs"] = _warmup_state["logs"][-500:]
92
-
93
-
94
- def _do_warmup():
95
- """Télécharge les modèles depuis le Hub de façon séquentielle.
96
- S'il y a erreur sur un repo, on log et on continue les suivants.
97
- """
98
- with _warmup_lock:
99
- _warmup_state.update({
100
- "status": "running",
101
- "current": None,
102
- "done": [],
103
- "errors": [],
104
- "started_at": time.time(),
105
- "ended_at": None,
106
- })
107
-
108
- # Import tardif pour éviter d'alourdir le boot du Space
109
- try:
110
- from huggingface_hub import snapshot_download
111
- try:
112
- # Certaines versions exposent l'exception ailleurs
113
- from huggingface_hub.utils import HfHubHTTPError # type: ignore
114
- except Exception: # pragma: no cover
115
- class HfHubHTTPError(Exception):
116
- pass
117
- except Exception as e: # Import fatal -> on sort
118
- _warm_log(f"ERREUR import huggingface_hub: {e}")
119
- with _warmup_lock:
120
- _warmup_state["status"] = "error"
121
- _warmup_state["ended_at"] = time.time()
122
- return
123
-
124
- repos: List[str] = [
125
- "facebook/sam2-hiera-large",
126
- "lixiaowen/diffuEraser",
127
- "runwayml/stable-diffusion-v1-5",
128
- "stabilityai/sd-vae-ft-mse",
129
- # Ajoute/retire selon besoin
130
- ]
131
-
132
- # Emplacement cache HF (persiste tant que l'espace n'est pas rebuild/GC)
133
- HF_HOME = Path(os.getenv("HF_HOME", "/root/.cache/huggingface")).expanduser()
134
- HF_HOME.mkdir(parents=True, exist_ok=True)
135
-
136
- for repo in repos:
137
- with _warmup_lock:
138
- _warmup_state["current"] = repo
139
- _warm_log(f"Téléchargement: {repo}")
140
- try:
141
- dest = HF_HOME / repo.replace("/", "__")
142
- # skip s'il y a déjà des fichiers (warm-up one-shot)
143
- if dest.exists() and any(dest.iterdir()):
144
- _warm_log(f"→ Déjà présent, skip: {repo}")
145
- with _warmup_lock:
146
- _warmup_state["done"].append(repo)
147
- continue
148
-
149
- _ = snapshot_download(repo_id=repo, local_dir=str(dest), local_dir_use_symlinks=False)
150
- _warm_log(f"OK: {repo}")
151
- with _warmup_lock:
152
- _warmup_state["done"].append(repo)
153
- except Exception as e: # HfHubHTTPError ou autre
154
- _warm_log(f"ERREUR {repo}: {e}")
155
- with _warmup_lock:
156
- _warmup_state["errors"].append((repo, str(e)))
157
- finally:
158
- # Heartbeat pour l'UI
159
- time.sleep(0.25)
160
-
161
- with _warmup_lock:
162
- _warmup_state["status"] = "done" if not _warmup_state["errors"] else "error"
163
- _warmup_state["ended_at"] = time.time()
164
- _warm_log("Warm-up terminé.")
165
-
166
-
167
- @app.post("/warmup/start", tags=["hub"])
168
- def warmup_start():
169
- with _warmup_lock:
170
- if _warmup_state["status"] == "running":
171
- return {"ok": True, "status": "running"}
172
- _warmup_state["logs"].clear()
173
- t = threading.Thread(target=_do_warmup, daemon=True)
174
- t.start()
175
- return {"ok": True, "status": "running"}
176
-
177
-
178
- @app.get("/warmup/status", tags=["hub"])
179
- def warmup_status():
180
- with _warmup_lock:
181
- return {
182
- "status": _warmup_state["status"],
183
- "current": _warmup_state["current"],
184
- "done": list(_warmup_state["done"]),
185
- "errors": list(_warmup_state["errors"]),
186
- "logs": list(_warmup_state["logs"]),
187
- "started_at": _warmup_state["started_at"],
188
- "ended_at": _warmup_state["ended_at"],
189
- }
190
-
191
-
192
- @app.post("/warmup/clear", tags=["hub"])
193
- def warmup_clear():
194
- with _warmup_lock:
195
- _warmup_state["logs"].clear()
196
- _warmup_state["current"] = None
197
- _warmup_state["done"].clear()
198
- _warmup_state["errors"].clear()
199
- _warmup_state["status"] = "idle"
200
- _warmup_state["started_at"] = None
201
- _warmup_state["ended_at"] = None
202
- return {"ok": True}
203
-
204
- # ------------------------------ PROXY (idem) --------------------------------
205
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
206
  async def proxy_all(full_path: str, request: Request):
207
  base = get_backend_base().rstrip("/")
@@ -220,9 +76,11 @@ async def proxy_all(full_path: str, request: Request):
220
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
221
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
222
 
223
- # ---------------------------- Helpers & Progress -----------------------------
224
  progress_data: Dict[str, Dict[str, Any]] = {}
225
 
 
 
226
  def _is_video(p: Path) -> bool:
227
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
228
 
@@ -321,14 +179,10 @@ def _load_masks(vid: str) -> Dict[str, Any]:
321
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
322
  return {"video": vid, "masks": []}
323
 
324
- def _save_masks_atomic(vid: str, data: Dict[str, Any]):
325
- f = _mask_file(vid)
326
- tmp = f.with_suffix(".json.tmp")
327
- with _mask_io_lock:
328
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
329
- os.replace(tmp, f)
330
 
331
- # ------------------------------ Thumbs async --------------------------------
332
 
333
  def _gen_thumbs_background(video: Path, vid_stem: str):
334
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
@@ -407,28 +261,105 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
407
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
408
  progress_data[vid_stem]['done'] = True
409
 
410
- # ---------------------------------- API -------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  @app.get("/", tags=["meta"])
412
  def root():
413
  return {
414
  "ok": True,
415
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup/*"],
416
  }
417
 
418
- @app.get("/health", tags=["meta"])
419
  def health():
420
  return {"status": "ok"}
421
 
422
- @app.get("/_env", tags=["meta"])
423
  def env_info():
424
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
425
 
426
- @app.get("/files", tags=["io"])
427
  def files():
428
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
429
  return {"count": len(items), "items": items}
430
 
431
- @app.get("/meta/{vid}", tags=["io"])
432
  def video_meta(vid: str):
433
  v = DATA_DIR / vid
434
  if not v.exists():
@@ -438,7 +369,7 @@ def video_meta(vid: str):
438
  raise HTTPException(500, "Métadonnées indisponibles")
439
  return m
440
 
441
- @app.post("/upload", tags=["io"])
442
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
443
  ext = (Path(file.filename).suffix or ".mp4").lower()
444
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
@@ -459,11 +390,11 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
459
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
460
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
461
 
462
- @app.get("/progress/{vid_stem}", tags=["io"])
463
  def progress(vid_stem: str):
464
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
465
 
466
- @app.delete("/delete/{vid}", tags=["io"])
467
  def delete_video(vid: str):
468
  v = DATA_DIR / vid
469
  if not v.exists():
@@ -476,7 +407,7 @@ def delete_video(vid: str):
476
  print(f"[DELETE] {vid}", file=sys.stdout)
477
  return {"deleted": vid}
478
 
479
- @app.get("/frame_idx", tags=["io"])
480
  def frame_idx(vid: str, idx: int):
481
  v = DATA_DIR / vid
482
  if not v.exists():
@@ -492,7 +423,7 @@ def frame_idx(vid: str, idx: int):
492
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
493
  raise HTTPException(500, "Frame error")
494
 
495
- @app.get("/poster/{vid}", tags=["io"])
496
  def poster(vid: str):
497
  v = DATA_DIR / vid
498
  if not v.exists():
@@ -502,12 +433,8 @@ def poster(vid: str):
502
  return FileResponse(str(p), media_type="image/jpeg")
503
  raise HTTPException(404, "Poster introuvable")
504
 
505
- @app.get("/window/{vid}", tags=["io"])
506
- def window(vid: str, center: int = 0, count: int = 50, start: Optional[int] = None, end: Optional[int] = None):
507
- """Fenêtre de timeline.
508
- - Si start/end fournis: on respecte strictement [start, end) (portion isolée).
509
- - Sinon: chunk classique autour de center, taille "count".
510
- """
511
  v = DATA_DIR / vid
512
  if not v.exists():
513
  raise HTTPException(404, "Vidéo introuvable")
@@ -516,24 +443,9 @@ def window(vid: str, center: int = 0, count: int = 50, start: Optional[int] = No
516
  raise HTTPException(500, "Métadonnées indisponibles")
517
  frames = m["frames"]
518
  count = max(3, int(count))
519
-
520
- # Portion stricte si fournie
521
- if start is not None and end is not None:
522
- start = max(0, min(int(start), max(0, frames)))
523
- end = max(start, min(int(end), frames)) # fin exclusive
524
- n = max(0, end - start)
525
- items = []
526
- bust = int(time.time()*1000)
527
- for i in range(n):
528
- idx = start + i
529
- url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
530
- items.append({"i": i, "idx": idx, "url": url})
531
- sel = max(0, min(center - start, max(0, n-1)))
532
- return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
533
-
534
- # Sinon, fenêtre déroulante autour de center
535
  center = max(0, min(int(center), max(0, frames-1)))
536
  if frames <= 0:
 
537
  return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
538
  if frames <= count:
539
  start = 0; sel = center; n = frames
@@ -546,79 +458,70 @@ def window(vid: str, center: int = 0, count: int = 50, start: Optional[int] = No
546
  idx = start + i
547
  url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
548
  items.append({"i": i, "idx": idx, "url": url})
 
549
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
550
 
551
- # --------------------------------- Masques -----------------------------------
552
- @app.post("/mask", tags=["mask"])
553
  async def save_mask(payload: Dict[str, Any] = Body(...)):
554
- try:
555
- vid = payload.get("vid")
556
- if not vid:
557
- raise HTTPException(400, "vid manquant")
558
- pts = payload.get("points") or []
559
- if len(pts) != 4:
560
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
561
- data = _load_masks(vid)
562
- m = {
563
- "id": uuid.uuid4().hex[:10],
564
- "time_s": float(payload.get("time_s") or 0.0),
565
- "frame_idx": int(payload.get("frame_idx") or 0),
566
- "shape": "rect",
567
- "points": [float(x) for x in pts], # normalisées [0..1]
568
- "color": payload.get("color") or "#10b981",
569
- "note": (payload.get("note") or "").strip()
570
- }
571
- data.setdefault("masks", []).append(m)
572
- _save_masks_atomic(vid, data)
573
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
574
- return {"saved": True, "mask": m}
575
- except HTTPException:
576
- raise
577
- except Exception as e:
578
- print(f"[MASK] save ERROR: {e}", file=sys.stdout)
579
- # Retourner une erreur JSON propre pour éviter une 500 HTML upstream
580
- return Response(
581
- content=json.dumps({"saved": False, "error": str(e)}),
582
- media_type="application/json",
583
- status_code=500,
584
- )
585
-
586
- @app.get("/mask/{vid}", tags=["mask"])
587
  def list_masks(vid: str):
588
  return _load_masks(vid)
589
 
590
- @app.post("/mask/rename", tags=["mask"])
591
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
592
- vid = payload.get("vid"); mid = payload.get("id"); new_note = (payload.get("note") or "").strip()
 
 
593
  if not vid or not mid:
594
  raise HTTPException(400, "vid et id requis")
595
  data = _load_masks(vid)
596
  for m in data.get("masks", []):
597
  if m.get("id") == mid:
598
  m["note"] = new_note
599
- _save_masks_atomic(vid, data)
600
  return {"ok": True}
601
  raise HTTPException(404, "Masque introuvable")
602
 
603
- @app.post("/mask/delete", tags=["mask"])
604
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
605
- vid = payload.get("vid"); mid = payload.get("id")
 
606
  if not vid or not mid:
607
  raise HTTPException(400, "vid et id requis")
608
  data = _load_masks(vid)
609
- before = len(data.get("masks", []))
610
  data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
611
- after = len(data.get("masks", []))
612
- _save_masks_atomic(vid, data)
613
- return {"ok": True, "removed": before - after}
614
 
615
- # ----------------------------------- UI -------------------------------------
616
  HTML_TEMPLATE = r"""
617
  <!doctype html>
618
  <html lang="fr"><meta charset="utf-8">
619
- <title>🎬 Video Editor</title>
620
  <style>
621
- :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb; --thumbH:110px}
622
  *{box-sizing:border-box}
623
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
624
  h1{margin:0 0 8px 0}
@@ -627,18 +530,19 @@ HTML_TEMPLATE = r"""
627
  .muted{color:var(--muted);font-size:13px}
628
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
629
  .viewer{max-width:1024px;margin:0 auto; position:relative}
630
- .player-wrap{position:relative; padding-bottom: var(--controlsH);}
631
  video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
632
  #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
633
  .timeline-container{margin-top:10px}
634
  .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%}
635
  .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
636
  .thumb:hover{transform:scale(1.05)}
637
- .thumb img{height:var(--thumbH);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
638
  .thumb img.sel{border-color:var(--active-border)}
639
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
640
  .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}
641
  .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
 
642
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
643
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
644
  .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
@@ -652,30 +556,44 @@ HTML_TEMPLATE = r"""
652
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
653
  .delete-btn:hover{color:#b91c1c}
654
  #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
 
 
655
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
656
  #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
657
  #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}
658
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
659
  #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
660
  #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
 
 
 
661
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
662
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
663
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
664
- .mini{font-size:12px;color:#6b7280}
665
- .row-slim{display:flex;gap:8px;align-items:center}
 
 
 
 
666
  </style>
667
  <h1>🎬 Video Editor</h1>
668
  <div class="topbar card">
669
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
670
- <strong>Charger une vidéo :</strong>
671
- <input type="file" name="file" accept="video/*" required>
672
- <button class="btn" type="submit">Uploader</button>
673
  </form>
674
  <span class="muted" id="msg">__MSG__</span>
675
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
676
- <span style="flex:1"></span>
677
- <button class="btn" id="warmStart">⚡ Warm‑up Model Hub</button>
678
- <button class="btn" id="warmClear" title="Effacer logs warm‑up">🧹</button>
 
 
 
 
 
679
  </div>
680
  <div class="layout">
681
  <div>
@@ -692,11 +610,14 @@ HTML_TEMPLATE = r"""
692
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
693
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
694
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
695
- <span class="mini" style="margin-left:8px">Astuce: fin = exclusive (ex: 1→55 joue 1..54)</span>
 
 
696
  </div>
697
  </div>
698
  <div class="card timeline-container">
699
- <div class="row-slim" style="margin:4px 0 8px 0;gap:8px;align-items:center">
 
700
  <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
701
  <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
702
  <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
@@ -706,20 +627,21 @@ HTML_TEMPLATE = r"""
706
  </div>
707
  <div id="timeline" class="timeline"></div>
708
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
 
709
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
710
  </div>
711
  </div>
712
  <div class="card tools">
713
- <div class="row-slim"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
714
- <div class="row-slim" style="margin-top:6px">
715
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
716
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
717
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
718
- <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection en cours (non enregistrée)">🧽 Effacer sélection</button>
719
  </div>
720
  <div style="margin-top:10px">
721
  <div class="muted">Couleur</div>
722
- <div class="row-slim" id="palette" style="margin-top:6px">
723
  <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
724
  <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
725
  <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
@@ -737,163 +659,643 @@ HTML_TEMPLATE = r"""
737
  <strong>Vidéos disponibles</strong>
738
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
739
  </div>
 
740
  </div>
741
  </div>
 
 
 
 
 
742
  <div id="toast"></div>
743
  <script>
744
- // ==== ÉTAT GLOBAL ====
745
- const serverVid = "__VID__"; const serverMsg = "__MSG__";
746
- const statusEl = document.getElementById('msg'); statusEl.textContent = serverMsg;
747
- const player = document.getElementById('player'); const srcEl = document.getElementById('vidsrc');
748
- const canvas = document.getElementById('editCanvas'); const ctx = canvas.getContext('2d');
749
- const modeLabel = document.getElementById('modeLabel'); const btnEdit = document.getElementById('btnEdit');
750
- const btnBack = document.getElementById('btnBack'); const btnSave = document.getElementById('btnSave');
751
- const btnClear= document.getElementById('btnClear'); const tlBox = document.getElementById('timeline');
752
- const btnFollow = document.getElementById('btnFollow'); const btnFilterMasked = document.getElementById('btnFilterMasked');
753
- const zoomSlider = document.getElementById('zoomSlider'); const maskedCount = document.getElementById('maskedCount');
754
- const gotoInput = document.getElementById('gotoInput'); const gotoBtn = document.getElementById('gotoBtn');
755
- const goFrame = document.getElementById('goFrame'); const endPortion = document.getElementById('endPortion');
756
- const isolerBoucle = document.getElementById('isolerBoucle'); const resetFull = document.getElementById('resetFull');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  const toastWrap = document.getElementById('toast');
758
- const warmStart = document.getElementById('warmStart'); const warmClear = document.getElementById('warmClear');
759
-
760
- let vidName = serverVid || ''; function fileStem(name){ const i=name.lastIndexOf('.'); return i>0?name.slice(0,i):name; }
761
- let vidStem = ''; let bustToken = Date.now();
762
- let fps = 30, frames = 0; let currentIdx = 0; let isPaused = true;
 
 
 
 
 
 
 
 
 
 
763
  let mode = 'view';
 
764
  let color = '#10b981';
765
- // \n Rectangles enregistrés par frame: Map<int, Array<[x1,y1,x2,y2,color]>> (coords normalisées)
766
- const rectMap = new Map();
767
- // Brouillon courant (non enregistré): {x1,y1,x2,y2,color} en pixels Canvas
768
- let draft = null; let dragging=false, sx=0, sy=0;
769
- // Divers
770
- let maskedSet = new Set(); let timelineUrls = []; let maskedOnlyMode=false;
771
- let viewStart = 0, viewEnd = 0; let followMode = false; let lastCenterMs=0; const CENTER_MS=150;
772
-
773
- function showToast(msg){ const el=document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>el.remove(),2000); }
774
-
775
- // ==== Canvas & Draw ====
776
- function fitCanvas(){ const r=player.getBoundingClientRect(); const ctrlH=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH')); canvas.width=Math.round(r.width); canvas.height=Math.round(r.height - ctrlH); canvas.style.width=r.width+'px'; canvas.style.height=(r.height-ctrlH)+'px'; draw(); }
777
-
778
- function draw(){ ctx.clearRect(0,0,canvas.width,canvas.height);
779
- // Masques enregistrés sur frame courante
780
- const arr = rectMap.get(currentIdx) || [];
781
- for(const m of arr){ const [nx1,ny1,nx2,ny2,mc] = m; const x1=nx1*canvas.width, y1=ny1*canvas.height, x2=nx2*canvas.width, y2=ny2*canvas.height; const x=Math.min(x1,x2), y=Math.min(y1,y2); const w=Math.abs(x2-x1), h=Math.abs(y2-y1); ctx.strokeStyle=mc||'#10b981'; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(mc||'#10b981')+'28'; ctx.fillRect(x,y,w,h); }
782
- // Brouillon
783
- if(draft){ const x=Math.min(draft.x1,draft.x2), y=Math.min(draft.y1,draft.y2); const w=Math.abs(draft.x2-draft.x1), h=Math.abs(draft.y2-draft.y1); ctx.strokeStyle=draft.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(draft.color||color)+'28'; ctx.fillRect(x,y,w,h); }
 
 
 
 
 
 
 
784
  }
785
-
786
- canvas.addEventListener('mousedown',(e)=>{ if(mode!=='edit'||!vidName) return; dragging=true; const r=canvas.getBoundingClientRect(); sx=e.clientX-r.left; sy=e.clientY-r.top; draft={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw(); });
787
- canvas.addEventListener('mousemove',(e)=>{ if(!dragging||!draft) return; const r=canvas.getBoundingClientRect(); draft.x2=e.clientX-r.left; draft.y2=e.clientY-r.top; draw(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
789
-
790
- btnClear.onclick=()=>{ draft=null; draw(); };
791
  btnEdit.onclick =()=> setMode('edit');
792
  btnBack.onclick =()=> setMode('view');
793
-
794
- function setMode(m){ mode=m; if(m==='edit'){ player.pause(); isPaused=true; player.controls=false; modeLabel.textContent='Édition'; btnEdit.style.display='none'; btnBack.style.display='inline-block'; btnSave.style.display='inline-block'; btnClear.style.display='inline-block'; canvas.style.pointerEvents='auto'; } else { player.controls=true; modeLabel.textContent='Lecture'; btnEdit.style.display='inline-block'; btnBack.style.display='none'; btnSave.style.display='none'; btnClear.style.display='none'; canvas.style.pointerEvents='none'; draft=null; } draw(); }
795
-
796
  // Palette
797
- const palette = document.getElementById('palette');
798
- palette.querySelectorAll('.swatch').forEach(el=>{ if(el.dataset.c===color) el.classList.add('sel'); el.onclick=()=>{ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel')); el.classList.add('sel'); color=el.dataset.c; if(draft){ draft.color=color; draw(); } }; });
799
-
800
- // ==== Timeline (simplifié ici, logique portion/centrage côté serveur déjà OK) ====
801
- const tlProgressFill = document.getElementById('tl-progress-fill');
802
- let thumbEls = new Map();
803
- function addThumb(idx){ if(thumbEls.has(idx)) return; const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx; if(maskedSet.has(idx)) wrap.classList.add('hasmask'); const img=new Image(); img.title='frame '+(idx+1); img.src=timelineUrls[idx]; if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); } img.onclick=async()=>{ goToIdx(idx,true); }; wrap.appendChild(img); const label=document.createElement('span'); label.className='thumb-label'; label.textContent=`#${idx+1}`; wrap.appendChild(label); tlBox.appendChild(wrap); thumbEls.set(idx, wrap); }
804
-
805
- function centerThumb(idx){ const el=thumbEls.get(idx); if(!el) return; const img=el.querySelector('img'); if(!img||!img.complete||img.naturalWidth===0){ setTimeout(()=>centerThumb(idx),25); return; } tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2); }
806
-
807
- async function renderPortion(start,end){ tlBox.innerHTML=''; thumbEls.clear(); for(let i=start;i<end;i++){ addThumb(i); } setTimeout(()=>centerThumb(currentIdx),0); }
808
-
809
- async function renderWindow(center){ // fenêtre 50
810
- const r=await fetch(`/window/${encodeURIComponent(vidName)}?center=${center}&count=50`); const d=await r.json(); tlBox.innerHTML=''; thumbEls.clear(); timelineUrls=[]; const bust=Date.now(); d.items.forEach(it=>{ timelineUrls[it.idx]=it.url; addThumb(it.idx); }); setTimeout(()=>centerThumb(currentIdx),0);
 
 
811
  }
812
-
813
- function goToIdx(idx,centerNow){ currentIdx=idx; player.currentTime = (fps>0? idx/fps : 0); tlBox.querySelectorAll('img.sel,img.sel-strong').forEach(x=>x.classList.remove('sel','sel-strong')); const el=thumbEls.get(idx); if(el){ const im=el.querySelector('img'); if(im){ im.classList.add('sel'); if(isPaused) im.classList.add('sel-strong'); } } if(centerNow) centerThumb(idx); draw(); }
814
-
815
- // ==== Masques: load/list UI ====
816
- const maskList = document.getElementById('maskList');
817
- async function loadMasks(){ const r=await fetch('/mask/'+encodeURIComponent(vidName)); const d=await r.json(); const arr=d.masks||[]; rectMap.clear(); maskedSet = new Set(arr.map(m=>parseInt(m.frame_idx||0,10))); maskedCount.textContent = `(${maskedSet.size} ⭐)`; for(const m of arr){ if(m.shape==='rect'){ const fr = parseInt(m.frame_idx||0,10); if(!rectMap.has(fr)) rectMap.set(fr, []); const [x1,y1,x2,y2]=m.points; rectMap.get(fr).push([x1,y1,x2,y2,m.color||'#10b981']); } }
818
- // Liste
819
- if(!arr.length){ maskList.textContent='—'; } else { maskList.innerHTML=''; const ul=document.createElement('ul'); ul.className='clean'; for(const m of arr){ const li=document.createElement('li'); const fr=(parseInt(m.frame_idx||0,10)+1); const t=(m.time_s||0).toFixed(2); const col=m.color||'#10b981'; const label=(m.note||(`frame ${fr}`)).replace(/</g,'&lt;').replace(/>/g,'&gt;'); 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}</strong> — #${fr} · t=${t}s`;
820
- const rn=document.createElement('span'); rn.className='rename-btn'; rn.textContent='✏️'; rn.title='Renommer'; rn.onclick=async()=>{ const nv=prompt('Nouveau nom du masque :', label); if(nv===null) return; await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})}); await loadMasks(); draw(); };
821
- const del=document.createElement('span'); del.className='delete-btn'; del.textContent='❌'; del.title='Supprimer'; del.onclick=async()=>{ if(!confirm(`Supprimer masque "${label}" ?`)) return; await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})}); await loadMasks(); draw(); };
822
- li.appendChild(rn); li.appendChild(del); ul.appendChild(li); }
823
- maskList.appendChild(ul);
824
  }
825
- draw();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  }
827
-
828
- // Enregistrer le brouillon courant -> masque
829
- btnSave.onclick = async ()=>{
830
- if(!draft || !vidName){ alert('Aucune sélection.'); return; }
831
- const defaultName = `frame ${currentIdx+1}`; const note=(prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
832
- const nx1=Math.min(draft.x1,draft.x2)/canvas.width, ny1=Math.min(draft.y1,draft.y2)/canvas.height; const nx2=Math.max(draft.x1,draft.x2)/canvas.width, ny2=Math.max(draft.y1,draft.y2)/canvas.height;
833
- const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[nx1,ny1,nx2,ny2],color:draft.color||color,note};
834
- try{ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const txt=await r.text(); if(!r.ok){ throw new Error('HTTP '+r.status+' '+txt); } const j=JSON.parse(txt); if(!j.saved){ throw new Error(j.error||'save failed'); }
835
- // Mettre à jour rectMap (multi-rects OK)
836
- if(!rectMap.has(currentIdx)) rectMap.set(currentIdx, []); rectMap.get(currentIdx).push([nx1,ny1,nx2,ny2,payload.color]); draft=null; await loadMasks(); showToast('Masque enregistré ✅'); draw(); }
837
- catch(e){ alert('Échec enregistrement masque: '+e); }
838
- };
839
-
840
- // ==== Follow/Filter/Zoom/Goto ====
841
- btnFollow.onclick=()=>{ followMode=!followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerThumb(currentIdx); };
842
- btnFilterMasked.onclick=async()=>{ maskedOnlyMode=!maskedOnlyMode; btnFilterMasked.classList.toggle('toggled', maskedOnlyMode); await renderForState(); };
843
- zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value+'px'); });
844
- function validFrameNumber(v){ return Number.isFinite(v) && v>=1 && v<=frames; }
845
- async function gotoFrameNum(){ const v=parseInt(gotoInput.value||'',10); if(!validFrameNumber(v)) return; isPaused=true; player.pause(); goToIdx(v-1,true); await renderForState(); centerThumb(currentIdx); }
846
- ['click'].forEach(ev=>gotoBtn.addEventListener(ev,gotoFrameNum));
847
-
848
- // ==== Portion ====
849
- async function renderForState(){ if(portionStart!=null && portionEnd!=null){ await renderPortion(portionStart, portionEnd); } else { await renderWindow(currentIdx); } }
850
- let portionStart=null, portionEnd=null; function idxToSec(i){ return (fps||30)>0? (i/fps) : 0; }
851
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  isolerBoucle.onclick = async ()=>{
853
- const start = Math.max(0, (parseInt(goFrame.value||'1',10)-1)); const end=parseInt(endPortion.value||'',10);
854
- if(!end || end<=start || end>frames){ alert('Portion invalide (fin > début).'); return; }
855
- portionStart=start; portionEnd=end; isPaused=true; player.pause(); goToIdx(start,true); await renderForState(); resetFull.style.display='inline-block'; };
856
-
857
- resetFull.onclick=async()=>{ portionStart=null; portionEnd=null; endPortion.value=''; goFrame.value=1; await renderForState(); resetFull.style.display='none'; };
858
-
859
- // ==== Player events ====
860
- player.addEventListener('loadedmetadata', ()=>{ fitCanvas(); draft=null; draw(); });
861
- window.addEventListener('resize', fitCanvas);
862
- player.addEventListener('pause', ()=>{ isPaused=true; const el=thumbEls.get(currentIdx); if(el){ const im=el.querySelector('img'); if(im){ im.classList.add('sel-strong'); } } });
863
- player.addEventListener('play', ()=>{ isPaused=false; tlBox.querySelectorAll('img.sel-strong').forEach(x=>x.classList.remove('sel-strong')); });
864
- player.addEventListener('timeupdate', ()=>{ const idx=Math.max(0, Math.min(frames-1, Math.round(player.currentTime*(fps||30)))); if(idx!==currentIdx){ currentIdx=idx; if(followMode && !isPaused){ const now=Date.now(); if(now-lastCenterMs>CENTER_MS){ lastCenterMs=now; centerThumb(currentIdx); } } draw(); } });
865
-
866
- goFrame.addEventListener('change', async ()=>{ const v=Math.max(1, parseInt(goFrame.value||'1',10)); player.pause(); isPaused=true; goToIdx(v-1,true); await renderForState(); });
867
-
868
- // ==== Warm‑up boutons ====
869
- warmStart.onclick=async()=>{ await fetch('/warmup/start',{method:'POST'}); showToast('Warm‑up démarré'); pollWarm(); };
870
- warmClear.onclick=async()=>{ await fetch('/warmup/clear',{method:'POST'}); showToast('Warm‑up: logs nettoyés'); };
871
-
872
- async function pollWarm(){ const r=await fetch('/warmup/status'); const s=await r.json(); const bar = document.getElementById('tl-progress-fill'); let txt=''; if(s.status==='running'){ const cur=s.current?(' → '+s.current):''; txt = `Warm‑up: running${cur} · ok=${(s.done||[]).length} · err=${(s.errors||[]).length}`; } else if(s.status==='done'){ txt='Warm‑up: ✅ terminé'; } else if(s.status==='error'){ txt='Warm‑up: ⚠️ terminé avec erreurs'; } else { txt=''; }
873
- statusEl.textContent = txt || serverMsg;
874
- if(s.status==='running'){ bar.style.width = '60%'; setTimeout(pollWarm,900); } else { bar.style.width = '0%'; }
875
  }
876
-
877
- // ==== Boot ====
878
- async function loadFiles(){ const r=await fetch('/files'); const d=await r.json(); const fileList=document.getElementById('fileList'); if(!d.items||!d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; } fileList.innerHTML=''; d.items.forEach(name=>{ const li=document.createElement('li'); const del=document.createElement('span'); del.className='delete-btn'; del.textContent='❌'; del.title='Supprimer'; del.onclick=async()=>{ if(!confirm(`Supprimer "${name}" ?`)) return; await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'}); loadFiles(); if(vidName===name){ vidName=''; initVideo(); } }; const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); li.appendChild(del); li.appendChild(a); fileList.appendChild(li); }); }
879
-
880
- async function initVideo(){ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; } vidStem = fileStem(vidName); bustToken = Date.now(); const bust=Date.now(); srcEl.src='/data/'+encodeURIComponent(vidName)+'?t='+bust; player.setAttribute('poster','/poster/'+encodeURIComponent(vidName)+'?t='+bust); player.load(); fitCanvas(); statusEl.textContent='Chargement vidéo…'; try{ const r=await fetch('/meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; statusEl.textContent=`OK (${frames} frames @ ${fps.toFixed(2)} fps)`; viewStart=0; viewEnd=frames; // urls timeline (référence)
881
- timelineUrls=[]; for(let i=0;i<frames;i++){ timelineUrls[i] = `/thumbs/f_${vidStem}_${i}.jpg?b=${bust}`; }
882
- await loadMasks(); await renderWindow(0); } else { statusEl.textContent='Erreur meta'; } }catch{ statusEl.textContent='Erreur réseau meta'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  }
884
-
885
- async function boot(){ await loadFiles(); if(serverVid){ vidName=serverVid; await initVideo(); } else { const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await initVideo(); } } pollWarm(); }
886
  boot();
 
 
 
 
887
  </script>
888
  </html>
889
  """
890
 
891
- @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
892
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
893
- vid = v or ""
894
- try:
895
- msg = urllib.parse.unquote(msg or "")
896
- except Exception:
897
- pass
898
- html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
899
- return HTMLResponse(content=html)
 
1
+ # app.py — Video Editor API (v0.8.2)
2
+ # Fixes:
3
+ # - Portion rendering shows full selected range (e.g., 1→55, 70→480, 8→88)
4
+ # - Accurate centering for "Aller à #" (no offset drift)
5
+ # - Robust Warm-up (Hub) with safe imports + error surfacing (no silent crash)
6
+ # Base: compatible with previous v0.5.9 routes and UI
 
 
 
 
 
 
 
 
 
7
 
8
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
9
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
 
19
 
20
  print("[BOOT] Video Editor API starting…")
21
 
22
+ # --- POINTEUR DE BACKEND -----------------------------------------------------
23
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
24
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
25
  _backend_url_cache = {"url": None, "ts": 0.0}
 
45
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
46
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
47
 
48
+ app = FastAPI(title="Video Editor API", version="0.8.2")
49
 
50
+ # --- DATA DIRS ----------------------------------------------------------------
51
  DATA_DIR = Path("/app/data")
52
  THUMB_DIR = DATA_DIR / "_thumbs"
53
  MASK_DIR = DATA_DIR / "_masks"
54
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
55
  p.mkdir(parents=True, exist_ok=True)
56
 
 
 
 
57
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
58
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
59
 
60
+ # --- PROXY VERS LE BACKEND ----------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
62
  async def proxy_all(full_path: str, request: Request):
63
  base = get_backend_base().rstrip("/")
 
76
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
77
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
78
 
79
+ # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
80
  progress_data: Dict[str, Dict[str, Any]] = {}
81
 
82
+ # --- HELPERS ------------------------------------------------------------------
83
+
84
  def _is_video(p: Path) -> bool:
85
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
86
 
 
179
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
180
  return {"video": vid, "masks": []}
181
 
182
+ def _save_masks(vid: str, data: Dict[str, Any]):
183
+ _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
 
184
 
185
+ # --- THUMBS GENERATION BG -----------------------------------------------------
186
 
187
  def _gen_thumbs_background(video: Path, vid_stem: str):
188
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
 
261
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
262
  progress_data[vid_stem]['done'] = True
263
 
264
+ # --- WARM-UP (Hub) ------------------------------------------------------------
265
+ from huggingface_hub import snapshot_download
266
+ try:
267
+ from huggingface_hub.utils import HfHubHTTPError # 0.13+
268
+ except Exception:
269
+ class HfHubHTTPError(Exception):
270
+ pass
271
+
272
+ warmup_state: Dict[str, Any] = {
273
+ "state": "idle", # idle|running|done|error
274
+ "running": False,
275
+ "percent": 0,
276
+ "current": "",
277
+ "idx": 0,
278
+ "total": 0,
279
+ "log": [],
280
+ "started_at": None,
281
+ "finished_at": None,
282
+ "last_error": "",
283
+ }
284
+
285
+ WARMUP_MODELS: List[str] = [
286
+ "facebook/sam2-hiera-large",
287
+ "lixiaowen/diffuEraser",
288
+ "runwayml/stable-diffusion-v1-5",
289
+ "stabilityai/sd-vae-ft-mse",
290
+ "ByteDance/Sa2VA-4B",
291
+ "wangfuyun/PCM_Weights",
292
+ ]
293
+
294
+ def _append_warmup_log(msg: str):
295
+ warmup_state["log"].append(msg)
296
+ if len(warmup_state["log"]) > 200:
297
+ warmup_state["log"] = warmup_state["log"][-200:]
298
+
299
+ def _do_warmup():
300
+ token = os.getenv("HF_TOKEN", None)
301
+ warmup_state.update({
302
+ "state": "running", "running": True, "percent": 0,
303
+ "idx": 0, "total": len(WARMUP_MODELS), "current": "",
304
+ "started_at": time.time(), "finished_at": None, "last_error": "",
305
+ "log": []
306
+ })
307
+ try:
308
+ total = len(WARMUP_MODELS)
309
+ for i, repo in enumerate(WARMUP_MODELS):
310
+ warmup_state["current"] = repo
311
+ warmup_state["idx"] = i
312
+ base_pct = int((i / max(1, total)) * 100)
313
+ warmup_state["percent"] = min(99, base_pct)
314
+ _append_warmup_log(f"➡️ Téléchargement: {repo}")
315
+ try:
316
+ snapshot_download(repo_id=repo, token=token)
317
+ _append_warmup_log(f"✅ OK: {repo}")
318
+ except HfHubHTTPError as he:
319
+ _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
320
+ except Exception as e:
321
+ _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
322
+ # après chaque repo
323
+ warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
324
+ warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
325
+ except Exception as e:
326
+ warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
327
+ _append_warmup_log(f"❌ Warm-up erreur: {e}")
328
+
329
+ @app.post("/warmup/start", tags=["warmup"])
330
+ def warmup_start():
331
+ if warmup_state.get("running"):
332
+ return {"ok": False, "detail": "already running", "state": warmup_state}
333
+ t = threading.Thread(target=_do_warmup, daemon=True)
334
+ t.start()
335
+ return {"ok": True, "state": warmup_state}
336
+
337
+ @app.get("/warmup/status", tags=["warmup"])
338
+ def warmup_status():
339
+ return warmup_state
340
+
341
+ # --- API ROUTES ---------------------------------------------------------------
342
  @app.get("/", tags=["meta"])
343
  def root():
344
  return {
345
  "ok": True,
346
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup/start", "/warmup/status"]
347
  }
348
 
349
+ @app.get("/health", tags=["meta"])
350
  def health():
351
  return {"status": "ok"}
352
 
353
+ @app.get("/_env", tags=["meta"])
354
  def env_info():
355
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
356
 
357
+ @app.get("/files", tags=["io"])
358
  def files():
359
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
360
  return {"count": len(items), "items": items}
361
 
362
+ @app.get("/meta/{vid}", tags=["io"])
363
  def video_meta(vid: str):
364
  v = DATA_DIR / vid
365
  if not v.exists():
 
369
  raise HTTPException(500, "Métadonnées indisponibles")
370
  return m
371
 
372
+ @app.post("/upload", tags=["io"])
373
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
374
  ext = (Path(file.filename).suffix or ".mp4").lower()
375
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
 
390
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
391
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
392
 
393
+ @app.get("/progress/{vid_stem}", tags=["io"])
394
  def progress(vid_stem: str):
395
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
396
 
397
+ @app.delete("/delete/{vid}", tags=["io"])
398
  def delete_video(vid: str):
399
  v = DATA_DIR / vid
400
  if not v.exists():
 
407
  print(f"[DELETE] {vid}", file=sys.stdout)
408
  return {"deleted": vid}
409
 
410
+ @app.get("/frame_idx", tags=["io"])
411
  def frame_idx(vid: str, idx: int):
412
  v = DATA_DIR / vid
413
  if not v.exists():
 
423
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
424
  raise HTTPException(500, "Frame error")
425
 
426
+ @app.get("/poster/{vid}", tags=["io"])
427
  def poster(vid: str):
428
  v = DATA_DIR / vid
429
  if not v.exists():
 
433
  return FileResponse(str(p), media_type="image/jpeg")
434
  raise HTTPException(404, "Poster introuvable")
435
 
436
+ @app.get("/window/{vid}", tags=["io"])
437
+ def window(vid: str, center: int = 0, count: int = 21):
 
 
 
 
438
  v = DATA_DIR / vid
439
  if not v.exists():
440
  raise HTTPException(404, "Vidéo introuvable")
 
443
  raise HTTPException(500, "Métadonnées indisponibles")
444
  frames = m["frames"]
445
  count = max(3, int(count))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  center = max(0, min(int(center), max(0, frames-1)))
447
  if frames <= 0:
448
+ print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
449
  return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
450
  if frames <= count:
451
  start = 0; sel = center; n = frames
 
458
  idx = start + i
459
  url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
460
  items.append({"i": i, "idx": idx, "url": url})
461
+ print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
462
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
463
 
464
+ # ----- Masques ---------------------------------------------------------------
465
+ @app.post("/mask", tags=["mask"])
466
  async def save_mask(payload: Dict[str, Any] = Body(...)):
467
+ vid = payload.get("vid")
468
+ if not vid:
469
+ raise HTTPException(400, "vid manquant")
470
+ pts = payload.get("points") or []
471
+ if len(pts) != 4:
472
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
473
+ data = _load_masks(vid)
474
+ m = {
475
+ "id": uuid.uuid4().hex[:10],
476
+ "time_s": float(payload.get("time_s") or 0.0),
477
+ "frame_idx": int(payload.get("frame_idx") or 0),
478
+ "shape": "rect",
479
+ "points": [float(x) for x in pts],
480
+ "color": payload.get("color") or "#10b981",
481
+ "note": payload.get("note") or ""
482
+ }
483
+ data.setdefault("masks", []).append(m)
484
+ _save_masks(vid, data)
485
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
486
+ return {"saved": True, "mask": m}
487
+
488
+ @app.get("/mask/{vid}", tags=["mask"])
 
 
 
 
 
 
 
 
 
 
 
489
  def list_masks(vid: str):
490
  return _load_masks(vid)
491
 
492
+ @app.post("/mask/rename", tags=["mask"])
493
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
494
+ vid = payload.get("vid")
495
+ mid = payload.get("id")
496
+ new_note = (payload.get("note") or "").strip()
497
  if not vid or not mid:
498
  raise HTTPException(400, "vid et id requis")
499
  data = _load_masks(vid)
500
  for m in data.get("masks", []):
501
  if m.get("id") == mid:
502
  m["note"] = new_note
503
+ _save_masks(vid, data)
504
  return {"ok": True}
505
  raise HTTPException(404, "Masque introuvable")
506
 
507
+ @app.post("/mask/delete", tags=["mask"])
508
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
509
+ vid = payload.get("vid")
510
+ mid = payload.get("id")
511
  if not vid or not mid:
512
  raise HTTPException(400, "vid et id requis")
513
  data = _load_masks(vid)
 
514
  data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
515
+ _save_masks(vid, data)
516
+ return {"ok": True}
 
517
 
518
+ # --- UI ----------------------------------------------------------------------
519
  HTML_TEMPLATE = r"""
520
  <!doctype html>
521
  <html lang="fr"><meta charset="utf-8">
522
+ <title>Video Editor</title>
523
  <style>
524
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb}
525
  *{box-sizing:border-box}
526
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
527
  h1{margin:0 0 8px 0}
 
530
  .muted{color:var(--muted);font-size:13px}
531
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
532
  .viewer{max-width:1024px;margin:0 auto; position:relative}
533
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
534
  video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
535
  #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
536
  .timeline-container{margin-top:10px}
537
  .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%}
538
  .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
539
  .thumb:hover{transform:scale(1.05)}
540
+ .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
541
  .thumb img.sel{border-color:var(--active-border)}
542
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
543
  .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}
544
  .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
545
+ .timeline.filter-masked .thumb:not(.hasmask){display:none}
546
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
547
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
548
  .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
 
556
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
557
  .delete-btn:hover{color:#b91c1c}
558
  #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
559
+ .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
560
+ .portion-input{width:70px}
561
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
562
  #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
563
  #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}
564
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
565
  #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
566
  #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
567
+ .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
568
+ #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
569
+ #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}
570
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
571
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
572
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
573
+ /* Warmup UI */
574
+ #warmupBox{display:flex;align-items:center;gap:8px}
575
+ #warmup-progress{flex:1;height:8px;background:#eef2f7;border-radius:4px;overflow:hidden}
576
+ #warmup-progress > div{height:100%;width:0;background:#10b981;border-radius:4px}
577
+ #warmup-status{font-size:12px;color:#334155;min-width:140px}
578
+ .err{color:#b91c1c}
579
  </style>
580
  <h1>🎬 Video Editor</h1>
581
  <div class="topbar card">
582
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
583
+ <strong>Charger une vidéo :</strong>
584
+ <input type="file" name="file" accept="video/*" required>
585
+ <button class="btn" type="submit">Uploader</button>
586
  </form>
587
  <span class="muted" id="msg">__MSG__</span>
588
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
589
+ </div>
590
+ <div class="card" style="margin-bottom:10px">
591
+ <div id="warmupBox">
592
+ <button id="warmupBtn" class="btn">⚡ Warm‑up modèles (Hub)</button>
593
+ <div id="warmup-status">—</div>
594
+ <div id="warmup-progress"><div id="warmup-fill"></div></div>
595
+ </div>
596
+ <div class="muted" id="warmup-log" style="margin-top:6px;max-height:90px;overflow:auto"></div>
597
  </div>
598
  <div class="layout">
599
  <div>
 
610
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
611
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
612
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
613
+ <span class="muted" style="margin-left:10px">Astuce: fin = exclusive ("55" inclut #55)</span>
614
+ <span id="posInfo" style="margin-left:10px"></span>
615
+ <span id="status" style="margin-left:10px;color:#2563eb"></span>
616
  </div>
617
  </div>
618
  <div class="card timeline-container">
619
+ <h4 style="margin:2px 0 8px 0">Timeline</h4>
620
+ <div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center">
621
  <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
622
  <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
623
  <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
 
627
  </div>
628
  <div id="timeline" class="timeline"></div>
629
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
630
+ <div id="loading-indicator">Chargement des frames...</div>
631
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
632
  </div>
633
  </div>
634
  <div class="card tools">
635
+ <div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
636
+ <div class="row" style="margin-top:6px">
637
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
638
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
639
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
640
+ <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
641
  </div>
642
  <div style="margin-top:10px">
643
  <div class="muted">Couleur</div>
644
+ <div class="row" id="palette" style="margin-top:6px">
645
  <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
646
  <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
647
  <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
 
659
  <strong>Vidéos disponibles</strong>
660
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
661
  </div>
662
+ </div>
663
  </div>
664
  </div>
665
+ <div id="popup">
666
+ <h3>Génération thumbs en cours</h3>
667
+ <div id="popup-progress-bar"><div id="popup-progress-fill"></div></div>
668
+ <div id="popup-logs"></div>
669
+ </div>
670
  <div id="toast"></div>
671
  <script>
672
+ const serverVid = "__VID__";
673
+ const serverMsg = "__MSG__";
674
+ document.getElementById('msg').textContent = serverMsg;
675
+ // Elements
676
+ const statusEl = document.getElementById('status');
677
+ const player = document.getElementById('player');
678
+ const srcEl = document.getElementById('vidsrc');
679
+ const canvas = document.getElementById('editCanvas');
680
+ const ctx = canvas.getContext('2d');
681
+ const modeLabel = document.getElementById('modeLabel');
682
+ const btnEdit = document.getElementById('btnEdit');
683
+ const btnBack = document.getElementById('btnBack');
684
+ const btnSave = document.getElementById('btnSave');
685
+ const btnClear= document.getElementById('btnClear');
686
+ const posInfo = document.getElementById('posInfo');
687
+ const goFrame = document.getElementById('goFrame');
688
+ const palette = document.getElementById('palette');
689
+ const fileList= document.getElementById('fileList');
690
+ const tlBox = document.getElementById('timeline');
691
+ const tlNote = document.getElementById('tlNote');
692
+ const playerWrap = document.getElementById('playerWrap');
693
+ const loadingInd = document.getElementById('loading-indicator');
694
+ const isolerBoucle = document.getElementById('isolerBoucle');
695
+ const resetFull = document.getElementById('resetFull');
696
+ const endPortion = document.getElementById('endPortion');
697
+ const popup = document.getElementById('popup');
698
+ const popupLogs = document.getElementById('popup-logs');
699
+ const tlProgressFill = document.getElementById('tl-progress-fill');
700
+ const popupProgressFill = document.getElementById('popup-progress-fill');
701
+ const btnFollow = document.getElementById('btnFollow');
702
+ const btnFilterMasked = document.getElementById('btnFilterMasked');
703
+ const zoomSlider = document.getElementById('zoomSlider');
704
+ const maskedCount = document.getElementById('maskedCount');
705
+ const hud = document.getElementById('hud');
706
  const toastWrap = document.getElementById('toast');
707
+ const gotoInput = document.getElementById('gotoInput');
708
+ const gotoBtn = document.getElementById('gotoBtn');
709
+ // Warmup UI
710
+ const warmBtn = document.getElementById('warmupBtn');
711
+ const warmStat = document.getElementById('warmup-status');
712
+ const warmBar = document.getElementById('warmup-fill');
713
+ const warmLog = document.getElementById('warmup-log');
714
+
715
+ // State
716
+ let vidName = serverVid || '';
717
+ function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
718
+ let vidStem = '';
719
+ let bustToken = Date.now();
720
+ let fps = 30, frames = 0;
721
+ let currentIdx = 0;
722
  let mode = 'view';
723
+ let rect = null, dragging=false, sx=0, sy=0;
724
  let color = '#10b981';
725
+ let rectMap = new Map();
726
+ let masks = [];
727
+ let maskedSet = new Set();
728
+ let timelineUrls = [];
729
+ let portionStart = null;
730
+ let portionEnd = null; // exclusive
731
+ let loopInterval = null;
732
+ let chunkSize = 50;
733
+ let timelineStart = 0, timelineEnd = 0;
734
+ let viewRangeStart = 0, viewRangeEnd = 0;
735
+ const scrollThreshold = 100;
736
+ let followMode = false;
737
+ let isPaused = true;
738
+ let thumbEls = new Map();
739
+ let lastCenterMs = 0;
740
+ const CENTER_THROTTLE_MS = 150;
741
+ const PENDING_KEY = 've_pending_masks_v1';
742
+ let maskedOnlyMode = false;
743
+
744
+ // Utils
745
+ function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
746
+ function ensureOverlays(){
747
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
748
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
749
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
750
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
751
  }
752
+ function playheadEl(){ return document.getElementById('playhead'); }
753
+ function portionBand(){ return document.getElementById('portionBand'); }
754
+ function inHandle(){ return document.getElementById('inHandle'); }
755
+ function outHandle(){ return document.getElementById('outHandle'); }
756
+ function findThumbEl(idx){ return thumbEls.get(idx) || null; }
757
+ function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
758
+ function updateSelectedThumb(){
759
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
760
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
761
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
762
+ }
763
+ function rawCenterThumb(el){
764
+ const boxRect = tlBox.getBoundingClientRect();
765
+ const elRect = el.getBoundingClientRect();
766
+ const current = tlBox.scrollLeft;
767
+ const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
768
+ const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
769
+ tlBox.scrollTo({ left: target, behavior: 'auto' });
770
+ }
771
+ async function ensureThumbVisibleCentered(idx){
772
+ for(let k=0; k<50; k++){
773
+ const el = findThumbEl(idx);
774
+ if(el){
775
+ const img = el.querySelector('img');
776
+ if(!img.complete || img.naturalWidth === 0){
777
+ await new Promise(r=>setTimeout(r, 30));
778
+ }else{
779
+ rawCenterThumb(el);
780
+ await new Promise(r=>requestAnimationFrame(()=>r()));
781
+ updatePlayhead();
782
+ return true;
783
+ }
784
+ }else{
785
+ await new Promise(r=>setTimeout(r, 30));
786
+ }
787
+ }
788
+ return false;
789
+ }
790
+ function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
791
+ function updatePlayhead(){
792
+ ensureOverlays();
793
+ const el = findThumbEl(currentIdx);
794
+ const ph = playheadEl();
795
+ if(!el){ ph.style.display='none'; return; }
796
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
797
+ }
798
+ function updatePortionOverlays(){
799
+ ensureOverlays();
800
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
801
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
802
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
803
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
804
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
805
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
806
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
807
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
808
+ }
809
+ function nearestFrameIdxFromClientX(clientX){
810
+ const rect = tlBox.getBoundingClientRect();
811
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
812
+ let bestIdx = currentIdx, bestDist = Infinity;
813
+ for(const [idx, el] of thumbEls.entries()){
814
+ const mid = el.offsetLeft + el.clientWidth/2;
815
+ const d = Math.abs(mid - xIn);
816
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
817
+ }
818
+ return bestIdx;
819
+ }
820
+ function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
821
+ function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
822
+ function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
823
+ async function flushPending(){
824
+ const lst = loadPending(); if(!lst.length) return;
825
+ const kept = [];
826
+ for(const p of lst){
827
+ try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
828
+ catch{ kept.push(p); }
829
+ }
830
+ savePendingList(kept);
831
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
832
+ }
833
+ function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
834
+ function fitCanvas(){
835
+ const r=player.getBoundingClientRect();
836
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
837
+ canvas.width=Math.round(r.width);
838
+ canvas.height=Math.round(r.height - ctrlH);
839
+ canvas.style.width=r.width+'px';
840
+ canvas.style.height=(r.height - ctrlH)+'px';
841
+ syncTimelineWidth();
842
+ }
843
+ function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
844
+ function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
845
+ function setMode(m){
846
+ mode=m;
847
+ if(m==='edit'){
848
+ player.pause();
849
+ player.controls = false;
850
+ playerWrap.classList.add('edit-mode');
851
+ modeLabel.textContent='Édition';
852
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
853
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
854
+ canvas.style.pointerEvents='auto';
855
+ rect = rectMap.get(currentIdx) || null; draw();
856
+ }else{
857
+ player.controls = true;
858
+ playerWrap.classList.remove('edit-mode');
859
+ modeLabel.textContent='Lecture';
860
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
861
+ btnSave.style.display='none'; btnClear.style.display='none';
862
+ canvas.style.pointerEvents='none';
863
+ rect=null; draw();
864
+ }
865
+ }
866
+ function draw(){
867
+ ctx.clearRect(0,0,canvas.width,canvas.height);
868
+ if(rect){
869
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
870
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
871
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
872
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
873
+ }
874
+ }
875
+ canvas.addEventListener('mousedown',(e)=>{
876
+ if(mode!=='edit' || !vidName) return;
877
+ dragging=true; const r=canvas.getBoundingClientRect();
878
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
879
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
880
+ });
881
+ canvas.addEventListener('mousemove',(e)=>{
882
+ if(!dragging) return;
883
+ const r=canvas.getBoundingClientRect();
884
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
885
+ });
886
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
887
+ btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
 
888
  btnEdit.onclick =()=> setMode('edit');
889
  btnBack.onclick =()=> setMode('view');
 
 
 
890
  // Palette
891
+ palette.querySelectorAll('.swatch').forEach(el=>{
892
+ if(el.dataset.c===color) el.classList.add('sel');
893
+ el.onclick=()=>{
894
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
895
+ el.classList.add('sel'); color=el.dataset.c;
896
+ if(rect){ rect.color=color; draw(); }
897
+ };
898
+ });
899
+ // === Timeline ===
900
+ async function loadTimelineUrls(){
901
+ timelineUrls = [];
902
+ const stem = vidStem, b = bustToken;
903
+ for(let idx=0; idx<frames; idx++){
904
+ timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`;
905
+ }
906
+ tlProgressFill.style.width='0%';
907
  }
908
+ async function renderTimeline(centerIdx){
909
+ if(!vidName) return;
910
+ loadingInd.style.display='block';
911
+ if(timelineUrls.length===0) await loadTimelineUrls();
912
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
913
+ if(maskedOnlyMode){
914
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
915
+ for(const i of idxs){ addThumb(i,'append'); }
916
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
917
+ return;
 
 
918
  }
919
+ if (portionStart!=null && portionEnd!=null){
920
+ const s = Math.max(0, Math.min(portionStart, frames-1));
921
+ const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
922
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
923
+ for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
924
+ timelineStart = s;
925
+ timelineEnd = e;
926
+ viewRangeStart = s;
927
+ viewRangeEnd = e;
928
+ setTimeout(async ()=>{
929
+ syncTimelineWidth();
930
+ updateSelectedThumb();
931
+ await ensureThumbVisibleCentered(currentIdx);
932
+ loadingInd.style.display='none';
933
+ updatePlayhead();
934
+ updatePortionOverlays();
935
+ }, 0);
936
+ return;
937
+ }
938
+ await loadWindow(centerIdx ?? currentIdx);
939
+ loadingInd.style.display='none';
940
  }
941
+ async function loadWindow(centerIdx){
942
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
943
+ const rngStart = (viewRangeStart ?? 0);
944
+ const rngEnd = (viewRangeEnd ?? frames);
945
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
946
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
947
+ const end = Math.min(rngEnd, start + chunkSize);
948
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
949
+ timelineStart = start; timelineEnd = end;
950
+ setTimeout(async ()=>{
951
+ syncTimelineWidth();
952
+ updateSelectedThumb();
953
+ await ensureThumbVisibleCentered(currentIdx);
954
+ updatePortionOverlays();
955
+ },0);
956
+ }
957
+ function addThumb(idx, place='append'){
958
+ if(thumbEls.has(idx)) return;
959
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
960
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
961
+ const img=new Image(); img.title='frame '+(idx+1);
962
+ img.src=timelineUrls[idx];
963
+ img.onerror = () => {
964
+ const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
965
+ img.onerror = null;
966
+ img.src = fallback;
967
+ img.onload = () => {
968
+ const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
969
+ timelineUrls[idx] = nu;
970
+ img.src = nu;
971
+ img.onload = null;
972
+ };
973
+ };
974
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
975
+ img.onclick=async ()=>{
976
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
977
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
978
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
979
+ };
980
+ wrap.appendChild(img);
981
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
982
+ wrap.appendChild(label);
983
+ if(place==='append'){ tlBox.appendChild(wrap); }
984
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
985
+ else{ tlBox.appendChild(wrap); }
986
+ thumbEls.set(idx, wrap);
987
+ }
988
+ // Scroll chunk (mode normal uniquement)
989
+ tlBox.addEventListener('scroll', ()=>{
990
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
991
+ updatePlayhead(); updatePortionOverlays();
992
+ return;
993
+ }
994
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
995
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
996
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
997
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
998
+ timelineEnd = newEnd;
999
+ }
1000
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
1001
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
1002
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
1003
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
1004
+ timelineStart = newStart;
1005
+ }
1006
+ updatePlayhead(); updatePortionOverlays();
1007
+ });
1008
+ // Isoler & Boucle
1009
  isolerBoucle.onclick = async ()=>{
1010
+ const start = Math.max(0, parseInt(goFrame.value || '1',10) - 1);
1011
+ const endIn = parseInt(endPortion.value || '',10);
1012
+ if(!endPortion.value || endIn <= (start+1) || endIn > frames){ alert('Portion invalide (fin > début)'); return; }
1013
+ const endExclusive = Math.min(frames, endIn); // fin exclusive; "55" => inclut #55 (idx 54)
1014
+ portionStart = start;
1015
+ portionEnd = endExclusive;
1016
+ viewRangeStart = start; viewRangeEnd = endExclusive;
1017
+ player.pause(); isPaused = true;
1018
+ currentIdx = start; player.currentTime = idxToSec(start);
1019
+ await renderTimeline(currentIdx);
1020
+ resetFull.style.display = 'inline-block';
1021
+ startLoop(); updatePortionOverlays();
1022
+ };
1023
+ function startLoop(){
1024
+ if(loopInterval) clearInterval(loopInterval);
1025
+ if(portionEnd != null){
1026
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
1027
+ }
 
 
 
 
1028
  }
1029
+ resetFull.onclick = async ()=>{
1030
+ portionStart = null; portionEnd = null;
1031
+ viewRangeStart = 0; viewRangeEnd = frames;
1032
+ goFrame.value = 1; endPortion.value = '';
1033
+ player.pause(); isPaused = true;
1034
+ await renderTimeline(currentIdx);
1035
+ resetFull.style.display='none';
1036
+ clearInterval(loopInterval); updatePortionOverlays();
1037
+ };
1038
+ // Drag IN/OUT
1039
+ function attachHandleDrag(handle, which){
1040
+ let draggingH=false;
1041
+ function onMove(e){
1042
+ if(!draggingH) return;
1043
+ const idx = nearestFrameIdxFromClientX(e.clientX);
1044
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
1045
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
1046
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
1047
+ updatePortionOverlays();
1048
+ }
1049
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
1050
+ window.addEventListener('mousemove', onMove);
1051
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
1052
+ }
1053
+ ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1054
+ // Progress popup (thumbs)
1055
+ async function showProgress(vidStem){
1056
+ popup.style.display = 'block';
1057
+ const interval = setInterval(async () => {
1058
+ const r = await fetch('/progress/' + vidStem);
1059
+ const d = await r.json();
1060
+ tlProgressFill.style.width = d.percent + '%';
1061
+ popupProgressFill.style.width = d.percent + '%';
1062
+ popupLogs.innerHTML = (d.logs||[]).map(x=>String(x)).join('<br>');
1063
+ if(d.done){
1064
+ clearInterval(interval);
1065
+ popup.style.display = 'none';
1066
+ await renderTimeline(currentIdx);
1067
+ }
1068
+ }, 800);
1069
+ }
1070
+ // Meta & boot
1071
+ async function loadVideoAndMeta() {
1072
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1073
+ vidStem = fileStem(vidName); bustToken = Date.now();
1074
+ const bust = Date.now();
1075
+ srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1076
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
1077
+ player.load();
1078
+ fitCanvas();
1079
+ statusEl.textContent = 'Chargement vidéo…';
1080
+ try{
1081
+ const r=await fetch('/meta/'+encodeURIComponent(vidName));
1082
+ if(r.ok){
1083
+ const m=await r.json();
1084
+ fps=m.fps||30; frames=m.frames||0;
1085
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
1086
+ viewRangeStart = 0; viewRangeEnd = frames;
1087
+ await loadTimelineUrls();
1088
+ await loadMasks();
1089
+ currentIdx = 0; player.currentTime = 0;
1090
+ await renderTimeline(0);
1091
+ showProgress(vidStem);
1092
+ }else{
1093
+ statusEl.textContent = 'Erreur meta';
1094
+ }
1095
+ }catch(err){
1096
+ statusEl.textContent = 'Erreur réseau meta';
1097
+ }
1098
+ }
1099
+ player.addEventListener('loadedmetadata', async ()=>{
1100
+ fitCanvas();
1101
+ if(!frames || frames<=0){
1102
+ 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{}
1103
+ }
1104
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
1105
+ });
1106
+ window.addEventListener('resize', ()=>{ fitCanvas(); });
1107
+ player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1108
+ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1109
+ player.addEventListener('timeupdate', ()=>{
1110
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1111
+ currentIdx=timeToIdx(player.currentTime);
1112
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1113
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
1114
+ if(followMode && !isPaused){
1115
+ const now = Date.now();
1116
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1117
+ }
1118
+ });
1119
+
1120
+ goFrame.addEventListener('change', async ()=>{
1121
+ if(!vidName) return;
1122
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
1123
+ player.pause(); isPaused = true;
1124
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1125
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1126
+ await renderTimeline(currentIdx);
1127
+ await ensureThumbVisibleCentered(currentIdx);
1128
+ });
1129
+ // Follow / Filter / Zoom / Goto
1130
+ btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1131
+ btnFilterMasked.onclick = async ()=>{
1132
+ maskedOnlyMode = !maskedOnlyMode;
1133
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1134
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1135
+ await renderTimeline(currentIdx);
1136
+ await ensureThumbVisibleCentered(currentIdx);
1137
+ };
1138
+ zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1139
+ async function gotoFrameNum(){
1140
+ const v = parseInt(gotoInput.value||'',10);
1141
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1142
+ player.pause(); isPaused = true;
1143
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1144
+ goFrame.value = v;
1145
+ await renderTimeline(currentIdx);
1146
+ await ensureThumbVisibleCentered(currentIdx);
1147
+ }
1148
+ gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1149
+ gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1150
+ // Drag & drop upload
1151
+ const uploadZone = document.getElementById('uploadForm');
1152
+ uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1153
+ uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1154
+ uploadZone.addEventListener('drop', (e) => {
1155
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1156
+ const file = e.dataTransfer.files[0];
1157
+ if(file && file.type.startsWith('video/')){
1158
+ const fd = new FormData(); fd.append('file', file);
1159
+ fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1160
+ }
1161
+ });
1162
+ // Export placeholder
1163
+ document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1164
+ // Fichiers & masques
1165
+ async function loadFiles(){
1166
+ const r=await fetch('/files'); const d=await r.json();
1167
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1168
+ fileList.innerHTML='';
1169
+ d.items.forEach(name=>{
1170
+ const li=document.createElement('li');
1171
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1172
+ delBtn.onclick=async()=>{
1173
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1174
+ await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1175
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1176
+ };
1177
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1178
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1179
+ });
1180
+ }
1181
+ async function loadMasks(){
1182
+ loadingInd.style.display='block';
1183
+ const box=document.getElementById('maskList');
1184
+ const r=await fetch('/mask/'+encodeURIComponent(vidName));
1185
+ const d=await r.json();
1186
+ masks=d.masks||[];
1187
+ maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1188
+ rectMap.clear();
1189
+ // simple: un seul rect "actif" par frame (on affiche le dernier enregistré)
1190
+ masks.forEach(m=>{
1191
+ if(m.shape==='rect'){
1192
+ const [x1,y1,x2,y2] = m.points;
1193
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1194
+ rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1195
+ }
1196
+ });
1197
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1198
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1199
+ box.innerHTML='';
1200
+ const ul=document.createElement('ul'); ul.className='clean';
1201
+ masks.forEach(m=>{
1202
+ const li=document.createElement('li');
1203
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1204
+ const t=(m.time_s||0).toFixed(2);
1205
+ const col=m.color||'#10b981';
1206
+ const label=m.note||(`frame ${fr}`);
1207
+ 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`;
1208
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1209
+ renameBtn.onclick=async()=>{
1210
+ const nv=prompt('Nouveau nom du masque :', label);
1211
+ if(nv===null) return;
1212
+ const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1213
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1214
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1215
+ }
1216
+ };
1217
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1218
+ delMaskBtn.onclick=async()=>{
1219
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1220
+ const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1221
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1222
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1223
+ }
1224
+ };
1225
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1226
+ });
1227
+ box.appendChild(ul);
1228
+ loadingInd.style.display='none';
1229
+ }
1230
+ // Save mask (+ cache)
1231
+ btnSave.onclick = async ()=>{
1232
+ if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1233
+ const defaultName = `frame ${currentIdx+1}`;
1234
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1235
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1236
+ const x=Math.min(rect.x1,rect.x2)/normW;
1237
+ const y=Math.min(rect.y1,rect.y2)/normH;
1238
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1239
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1240
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:rect.color||color,note:note};
1241
+ addPending(payload);
1242
+ try{
1243
+ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1244
+ if(r.ok){
1245
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1246
+ savePendingList(lst);
1247
+ rectMap.set(currentIdx,{...rect});
1248
+ await loadMasks(); await renderTimeline(currentIdx);
1249
+ showToast('Masque enregistré ✅');
1250
+ } else {
1251
+ const txt = await r.text();
1252
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1253
+ }
1254
+ }catch(e){
1255
+ alert('Erreur réseau lors de l’\u00e9nregistrement du masque.');
1256
+ }
1257
+ };
1258
+ // Warm-up button
1259
+ warmBtn.onclick = async ()=>{
1260
+ try{ await fetch('/warmup/start',{method:'POST'}); }catch{}
1261
+ const poll = async ()=>{
1262
+ try{
1263
+ const r = await fetch('/warmup/status');
1264
+ const st = await r.json();
1265
+ warmBar.style.width = (st.percent||0) + '%';
1266
+ let txt = (st.state||'idle');
1267
+ if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1268
+ if(st.state==='error'){ txt += ' · erreur'; }
1269
+ warmStat.textContent = txt;
1270
+ const lines = (st.log||[]).slice(-6);
1271
+ warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
1272
+ if(st.state==='done' || st.state==='error') return; else setTimeout(poll, 1000);
1273
+ }catch{ setTimeout(poll, 1500); }
1274
+ };
1275
+ poll();
1276
+ };
1277
+ // Boot
1278
+ async function boot(){
1279
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1280
+ await loadFiles();
1281
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1282
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1283
  }
 
 
1284
  boot();
1285
+ // Hide controls in edit-mode
1286
+ const style = document.createElement('style');
1287
+ style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1288
+ document.head.appendChild(style);
1289
  </script>
1290
  </html>
1291
  """
1292
 
1293
+ @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1294
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1295
+ vid = v or ""
1296
+ try:
1297
+ msg = urllib.parse.unquote(msg or "")
1298
+ except Exception:
1299
+ pass
1300
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1301
+ return HTMLResponse(content=html)