FABLESLIP commited on
Commit
abf19cd
·
verified ·
1 Parent(s): c917187

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +828 -43
app.py CHANGED
@@ -1,8 +1,8 @@
1
- # app.py — Video Editor API (v0.5.10)
2
- # v0.5.10:
3
- # - Accepte deux jeux d'ENV: (BACKEND_POINTER_URL/BACKEND_BASE_URL) OU (POINTER_URL/FALLBACK_BASE)
4
- # - Ajout /_ping et /_env pour diagnostic rapide (sans mots interdits)
5
- # - Reste identique côté API/UI
6
 
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
8
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
@@ -18,32 +18,25 @@ import shutil as _shutil
18
  import os
19
  import httpx
20
 
21
- # Supporte tes anciens noms d'ENV ET les nouveaux :
22
- POINTER_URL = (
23
- os.getenv("BACKEND_POINTER_URL")
24
- or os.getenv("POINTER_URL")
25
- or ""
26
- ).strip()
27
-
28
- FALLBACK_BASE = (
29
- os.getenv("BACKEND_BASE_URL")
30
- or os.getenv("FALLBACK_BASE")
31
- or "http://127.0.0.1:8765"
32
- ).strip()
33
 
34
  _backend_url_cache = {"url": None, "ts": 0.0}
35
 
36
  def get_backend_base() -> str:
37
  """
38
  Renvoie l'URL du backend.
39
- - Si un pointeur d'URL est défini (fichier texte externe contenant l’URL publique courante),
40
- on lit le contenu et on le met en cache 30 s.
41
- - Sinon on utilise FALLBACK_BASE.
42
  """
43
  try:
44
  if POINTER_URL:
45
  now = time.time()
46
- need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
 
 
 
47
  if need_refresh:
48
  r = httpx.get(POINTER_URL, timeout=5)
49
  url = (r.text or "").strip()
@@ -58,10 +51,10 @@ def get_backend_base() -> str:
58
  return FALLBACK_BASE
59
  # ---------------------------------------------------------------------------
60
  print("[BOOT] Video Editor API starting…")
61
- print(f"[BOOT] POINTER_URL={'(set)' if POINTER_URL else '(unset)'}")
62
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
63
 
64
- app = FastAPI(title="Video Editor API", version="0.5.10")
65
 
66
  DATA_DIR = Path("/app/data")
67
  THUMB_DIR = DATA_DIR / "_thumbs"
@@ -72,7 +65,7 @@ for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
72
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
73
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
74
 
75
- # --- PROXY (pas de CORS côté navigateur) -------------------------------------
76
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
77
  async def proxy_all(full_path: str, request: Request):
78
  base = get_backend_base().rstrip("/")
@@ -126,9 +119,14 @@ def _meta(video: Path):
126
  return {"frames": frames, "fps": fps, "w": w, "h": h}
127
 
128
  def _frame_jpg(video: Path, idx: int) -> Path:
 
 
 
 
129
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
130
  if out.exists():
131
  return out
 
132
  if _has_ffmpeg():
133
  m = _meta(video) or {"fps": 30.0}
134
  fps = float(m.get("fps") or 30.0) or 30.0
@@ -147,6 +145,8 @@ def _frame_jpg(video: Path, idx: int) -> Path:
147
  return out
148
  except subprocess.CalledProcessError as e:
149
  print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
 
 
150
  cap = cv2.VideoCapture(str(video))
151
  if not cap.isOpened():
152
  print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
@@ -163,6 +163,7 @@ def _frame_jpg(video: Path, idx: int) -> Path:
163
  if not ok or img is None:
164
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
165
  raise HTTPException(500, "Impossible de lire la frame demandée.")
 
166
  h, w = img.shape[:2]
167
  if w > 320:
168
  new_w = 320
@@ -202,6 +203,11 @@ def _save_masks(vid: str, data: Dict[str, Any]):
202
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
203
 
204
  def _gen_thumbs_background(video: Path, vid_stem: str):
 
 
 
 
 
205
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
206
  try:
207
  m = _meta(video)
@@ -214,6 +220,8 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
214
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
215
  progress_data[vid_stem]['done'] = True
216
  return
 
 
217
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
218
  f.unlink(missing_ok=True)
219
 
@@ -229,6 +237,7 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
229
  ]
230
  progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
231
  proc = subprocess.Popen(cmd)
 
232
  last_report = -1
233
  while proc.poll() is None:
234
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
@@ -238,12 +247,14 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
238
  progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
239
  last_report = generated
240
  time.sleep(0.4)
 
241
  proc.wait()
242
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
243
  progress_data[vid_stem]['percent'] = 100
244
  progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
245
  progress_data[vid_stem]['done'] = True
246
  print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
 
247
  else:
248
  progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
249
  cap = cv2.VideoCapture(str(video))
@@ -258,6 +269,7 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
258
  if not ok or img is None:
259
  break
260
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
 
261
  h, w = img.shape[:2]
262
  if w > 320:
263
  new_w = 320
@@ -275,6 +287,7 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
275
  progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
276
  progress_data[vid_stem]['done'] = True
277
  print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
 
278
  except Exception as e:
279
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
280
  progress_data[vid_stem]['done'] = True
@@ -284,29 +297,13 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
284
  def root():
285
  return {
286
  "ok": True,
287
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/_ping", "/_env"]
288
  }
289
 
290
  @app.get("/health", tags=["meta"])
291
  def health():
292
  return {"status": "ok"}
293
 
294
- # Diagnostics simples (pour vérifier conteneur & ENV)
295
- @app.get("/_ping", tags=["meta"])
296
- def ping():
297
- return {"ok": True, "ts": time.time()}
298
-
299
- @app.get("/_env", tags=["meta"])
300
- def env_info():
301
- # On n’expose pas les secrets, juste des infos utiles
302
- resolved = get_backend_base()
303
- return {
304
- "pointer_set": bool(POINTER_URL),
305
- "pointer_url_length": len(POINTER_URL or ""),
306
- "fallback_base": FALLBACK_BASE,
307
- "resolved_base": resolved
308
- }
309
-
310
  @app.get("/files", tags=["io"])
311
  def files():
312
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
@@ -414,9 +411,797 @@ def window(vid: str, center: int = 0, count: int = 21):
414
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
415
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  # ---------- UI ----------
418
- HTML_TEMPLATE = r"""(… tout le HTML/JS de ton UI identique à ta version 0.5.9 …)"""
419
- # Pour gagner de la place ici, garde exactement ton HTML_TEMPLATE 0.5.9 précédent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
422
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
 
1
+ # app.py — Video Editor API (v0.5.9)
2
+ # v0.5.9:
3
+ # - Centrages "Aller à #" / "Frame #" 100% fiables (attend rendu + image chargée)
4
+ # - /mask, /mask/rename, /mask/delete : Body(...) explicite => plus de 422 silencieux
5
+ # - Bouton "Enregistrer masque" : erreurs visibles (alert) si l’API ne répond pas OK
6
 
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
8
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
 
18
  import os
19
  import httpx
20
 
21
+ POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
22
+ FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
 
 
 
 
 
 
 
 
 
 
23
 
24
  _backend_url_cache = {"url": None, "ts": 0.0}
25
 
26
  def get_backend_base() -> str:
27
  """
28
  Renvoie l'URL du backend.
29
+ - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
30
+ l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
31
+ - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
32
  """
33
  try:
34
  if POINTER_URL:
35
  now = time.time()
36
+ need_refresh = (
37
+ not _backend_url_cache["url"] or
38
+ now - _backend_url_cache["ts"] > 30
39
+ )
40
  if need_refresh:
41
  r = httpx.get(POINTER_URL, timeout=5)
42
  url = (r.text or "").strip()
 
51
  return FALLBACK_BASE
52
  # ---------------------------------------------------------------------------
53
  print("[BOOT] Video Editor API starting…")
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.5.9")
58
 
59
  DATA_DIR = Path("/app/data")
60
  THUMB_DIR = DATA_DIR / "_thumbs"
 
65
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
66
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
67
 
68
+ # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) --------------------
69
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
70
  async def proxy_all(full_path: str, request: Request):
71
  base = get_backend_base().rstrip("/")
 
119
  return {"frames": frames, "fps": fps, "w": w, "h": h}
120
 
121
  def _frame_jpg(video: Path, idx: int) -> Path:
122
+ """
123
+ Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
124
+ Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
125
+ """
126
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
127
  if out.exists():
128
  return out
129
+
130
  if _has_ffmpeg():
131
  m = _meta(video) or {"fps": 30.0}
132
  fps = float(m.get("fps") or 30.0) or 30.0
 
145
  return out
146
  except subprocess.CalledProcessError as e:
147
  print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
148
+ # fallback OpenCV
149
+
150
  cap = cv2.VideoCapture(str(video))
151
  if not cap.isOpened():
152
  print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
 
163
  if not ok or img is None:
164
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
165
  raise HTTPException(500, "Impossible de lire la frame demandée.")
166
+ # Redimension (≈320 px)
167
  h, w = img.shape[:2]
168
  if w > 320:
169
  new_w = 320
 
203
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
204
 
205
  def _gen_thumbs_background(video: Path, vid_stem: str):
206
+ """
207
+ Génère toutes les vignettes en arrière-plan :
208
+ - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
209
+ - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
210
+ """
211
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
212
  try:
213
  m = _meta(video)
 
220
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
221
  progress_data[vid_stem]['done'] = True
222
  return
223
+
224
+ # Nettoyer d’anciennes thumbs du même stem
225
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
226
  f.unlink(missing_ok=True)
227
 
 
237
  ]
238
  progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
239
  proc = subprocess.Popen(cmd)
240
+
241
  last_report = -1
242
  while proc.poll() is None:
243
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
 
247
  progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
248
  last_report = generated
249
  time.sleep(0.4)
250
+
251
  proc.wait()
252
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
253
  progress_data[vid_stem]['percent'] = 100
254
  progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
255
  progress_data[vid_stem]['done'] = True
256
  print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
257
+
258
  else:
259
  progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
260
  cap = cv2.VideoCapture(str(video))
 
269
  if not ok or img is None:
270
  break
271
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
272
+ # Redimension léger (≈320 px de large)
273
  h, w = img.shape[:2]
274
  if w > 320:
275
  new_w = 320
 
287
  progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
288
  progress_data[vid_stem]['done'] = True
289
  print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
290
+
291
  except Exception as e:
292
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
293
  progress_data[vid_stem]['done'] = True
 
297
  def root():
298
  return {
299
  "ok": True,
300
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
301
  }
302
 
303
  @app.get("/health", tags=["meta"])
304
  def health():
305
  return {"status": "ok"}
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  @app.get("/files", tags=["io"])
308
  def files():
309
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
 
411
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
412
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
413
 
414
+ # ----- Masques -----
415
+ @app.post("/mask", tags=["mask"])
416
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
417
+ vid = payload.get("vid")
418
+ if not vid:
419
+ raise HTTPException(400, "vid manquant")
420
+ pts = payload.get("points") or []
421
+ if len(pts) != 4:
422
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
423
+ data = _load_masks(vid)
424
+ m = {
425
+ "id": uuid.uuid4().hex[:10],
426
+ "time_s": float(payload.get("time_s") or 0.0),
427
+ "frame_idx": int(payload.get("frame_idx") or 0),
428
+ "shape": "rect",
429
+ "points": [float(x) for x in pts],
430
+ "color": payload.get("color") or "#10b981",
431
+ "note": payload.get("note") or ""
432
+ }
433
+ data.setdefault("masks", []).append(m)
434
+ _save_masks(vid, data)
435
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
436
+ return {"saved": True, "mask": m}
437
+
438
+ @app.get("/mask/{vid}", tags=["mask"])
439
+ def list_masks(vid: str):
440
+ return _load_masks(vid)
441
+
442
+ @app.post("/mask/rename", tags=["mask"])
443
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
444
+ vid = payload.get("vid")
445
+ mid = payload.get("id")
446
+ new_note = (payload.get("note") or "").strip()
447
+ if not vid or not mid:
448
+ raise HTTPException(400, "vid et id requis")
449
+ data = _load_masks(vid)
450
+ for m in data.get("masks", []):
451
+ if m.get("id") == mid:
452
+ m["note"] = new_note
453
+ _save_masks(vid, data)
454
+ return {"ok": True}
455
+ raise HTTPException(404, "Masque introuvable")
456
+
457
+ @app.post("/mask/delete", tags=["mask"])
458
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
459
+ vid = payload.get("vid")
460
+ mid = payload.get("id")
461
+ if not vid or not mid:
462
+ raise HTTPException(400, "vid et id requis")
463
+ data = _load_masks(vid)
464
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
465
+ _save_masks(vid, data)
466
+ return {"ok": True}
467
+
468
  # ---------- UI ----------
469
+ HTML_TEMPLATE = r"""
470
+ <!doctype html>
471
+ <html lang="fr"><meta charset="utf-8">
472
+ <title>Video Editor</title>
473
+ <style>
474
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
475
+ *{box-sizing:border-box}
476
+ body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
477
+ h1{margin:0 0 8px 0}
478
+ .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
479
+ .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
480
+ .muted{color:var(--muted);font-size:13px}
481
+ .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
482
+ .viewer{max-width:1024px;margin:0 auto; position:relative}
483
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
484
+ video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
485
+ #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
486
+ .timeline-container{margin-top:10px}
487
+ .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%}
488
+ .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
489
+ .thumb:hover{transform:scale(1.05)}
490
+ .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
491
+ .thumb img.sel{border-color:var(--active-border)}
492
+ .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
493
+ .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}
494
+ .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
495
+ .timeline.filter-masked .thumb:not(.hasmask){display:none}
496
+ .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
497
+ .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
498
+ .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
499
+ .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
500
+ .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
501
+ .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
502
+ ul.clean{list-style:none;padding-left:0;margin:6px 0}
503
+ ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
504
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:#64748b;transition:color 0.2s}
505
+ .rename-btn:hover{color:#2563eb}
506
+ .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
507
+ .delete-btn:hover{color:#b91c1c}
508
+ #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
509
+ .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
510
+ .portion-input{width:70px}
511
+ #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
512
+ #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
513
+ #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}
514
+ #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
515
+ #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
516
+ #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
517
+ .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
518
+ #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
519
+ #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}
520
+ #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
521
+ #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
522
+ .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
523
+ </style>
524
+ <h1>🎬 Video Editor</h1>
525
+ <div class="topbar card">
526
+ <form action="/p/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
527
+ <strong>Charger une vidéo :</strong>
528
+ <input type="file" name="file" accept="video/*" required>
529
+ <button class="btn" type="submit">Uploader</button>
530
+ </form>
531
+ <span class="muted" id="msg">__MSG__</span>
532
+ <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
533
+ </div>
534
+ <div class="layout">
535
+ <div>
536
+ <div class="viewer card" id="viewerCard">
537
+ <div class="player-wrap" id="playerWrap">
538
+ <video id="player" controls playsinline poster="/poster/__VID__">
539
+ <source id="vidsrc" src="/data/__VID__" type="video/mp4">
540
+ </video>
541
+ <canvas id="editCanvas"></canvas>
542
+ <div id="hud"></div>
543
+ </div>
544
+ <div class="muted" style="margin-top:6px">
545
+ <label>Frame # <input id="goFrame" type="number" min="1" value="1" style="width:90px"> </label>
546
+ <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
547
+ <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
548
+ <button id="resetFull" class="btn" style="display:none">Retour full</button>
549
+ <span id="posInfo" style="margin-left:10px"></span>
550
+ <span id="status" style="margin-left:10px;color:#2563eb"></span>
551
+ </div>
552
+ </div>
553
+ <div class="card timeline-container">
554
+ <h4 style="margin:2px 0 8px 0">Timeline</h4>
555
+ <div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center">
556
+ <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
557
+ <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
558
+ <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
559
+ <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
560
+ <button id="gotoBtn" class="btn">Aller</button>
561
+ <span class="muted" id="maskedCount"></span>
562
+ </div>
563
+ <div id="timeline" class="timeline"></div>
564
+ <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
565
+ <div id="loading-indicator">Chargement des frames...</div>
566
+ <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
567
+ </div>
568
+ </div>
569
+ <div class="card tools">
570
+ <div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
571
+ <div class="row" style="margin-top:6px">
572
+ <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
573
+ <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
574
+ <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
575
+ <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
576
+ </div>
577
+ <div style="margin-top:10px">
578
+ <div class="muted">Couleur</div>
579
+ <div class="row" id="palette" style="margin-top:6px">
580
+ <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
581
+ <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
582
+ <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
583
+ <div class="swatch" data-c="#f59e0b" style="background:#f59e0b" title="Orange"></div>
584
+ <div class="swatch" data-c="#a21caf" style="background:#a21caf" title="Violet"></div>
585
+ </div>
586
+ </div>
587
+ <div style="margin-top:12px">
588
+ <details open>
589
+ <summary><strong>Masques</strong></summary>
590
+ <div id="maskList" class="muted">—</div>
591
+ <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
592
+ </details>
593
+ <div style="margin-top:6px">
594
+ <strong>Vidéos disponibles</strong>
595
+ <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
596
+ </div>
597
+ </div>
598
+ </div>
599
+ <div id="popup">
600
+ <h3>Génération thumbs en cours</h3>
601
+ <div id="popup-progress-bar"><div id="popup-progress-fill"></div></div>
602
+ <div id="popup-logs"></div>
603
+ </div>
604
+ <div id="toast"></div>
605
+ <script>
606
+ const API_BASE = '';
607
+ const P = (p) => p;
608
+
609
+ const serverVid = "__VID__";
610
+ const serverMsg = "__MSG__";
611
+ document.getElementById('msg').textContent = serverMsg;
612
+ // Elements
613
+ const statusEl = document.getElementById('status');
614
+ const player = document.getElementById('player');
615
+ const srcEl = document.getElementById('vidsrc');
616
+ const canvas = document.getElementById('editCanvas');
617
+ const ctx = canvas.getContext('2d');
618
+ const modeLabel = document.getElementById('modeLabel');
619
+ const btnEdit = document.getElementById('btnEdit');
620
+ const btnBack = document.getElementById('btnBack');
621
+ const btnSave = document.getElementById('btnSave');
622
+ const btnClear= document.getElementById('btnClear');
623
+ const posInfo = document.getElementById('posInfo');
624
+ const goFrame = document.getElementById('goFrame');
625
+ const palette = document.getElementById('palette');
626
+ const fileList= document.getElementById('fileList');
627
+ const tlBox = document.getElementById('timeline');
628
+ const tlNote = document.getElementById('tlNote');
629
+ const playerWrap = document.getElementById('playerWrap');
630
+ const loadingInd = document.getElementById('loading-indicator');
631
+ const isolerBoucle = document.getElementById('isolerBoucle');
632
+ const resetFull = document.getElementById('resetFull');
633
+ const endPortion = document.getElementById('endPortion');
634
+ const popup = document.getElementById('popup');
635
+ const popupLogs = document.getElementById('popup-logs');
636
+ const tlProgressFill = document.getElementById('tl-progress-fill');
637
+ const popupProgressFill = document.getElementById('popup-progress-fill');
638
+ const btnFollow = document.getElementById('btnFollow');
639
+ const btnFilterMasked = document.getElementById('btnFilterMasked');
640
+ const zoomSlider = document.getElementById('zoomSlider');
641
+ const maskedCount = document.getElementById('maskedCount');
642
+ const hud = document.getElementById('hud');
643
+ const toastWrap = document.getElementById('toast');
644
+ const gotoInput = document.getElementById('gotoInput');
645
+ const gotoBtn = document.getElementById('gotoBtn');
646
+
647
+ // State
648
+ let vidName = serverVid || '';
649
+ function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
650
+ let vidStem = '';
651
+ let bustToken = Date.now();
652
+ let fps = 30, frames = 0;
653
+ let currentIdx = 0;
654
+ let mode = 'view';
655
+ let rect = null, dragging=false, sx=0, sy=0;
656
+ let color = '#10b981';
657
+ let rectMap = new Map();
658
+ let masks = [];
659
+ let maskedSet = new Set();
660
+ let timelineUrls = [];
661
+ let portionStart = null;
662
+ let portionEnd = null;
663
+ let loopInterval = null;
664
+ let chunkSize = 50;
665
+ let timelineStart = 0, timelineEnd = 0;
666
+ let viewRangeStart = 0, viewRangeEnd = 0; // portée visible (portion ou full)
667
+ const scrollThreshold = 100;
668
+ let followMode = false;
669
+ let isPaused = true;
670
+ let thumbEls = new Map();
671
+ let lastCenterMs = 0;
672
+ const CENTER_THROTTLE_MS = 150;
673
+ const PENDING_KEY = 've_pending_masks_v1';
674
+ let maskedOnlyMode = false;
675
+
676
+ // Utils
677
+ function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
678
+ function ensureOverlays(){
679
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
680
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
681
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
682
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
683
+ }
684
+ function playheadEl(){ return document.getElementById('playhead'); }
685
+ function portionBand(){ return document.getElementById('portionBand'); }
686
+ function inHandle(){ return document.getElementById('inHandle'); }
687
+ function outHandle(){ return document.getElementById('outHandle'); }
688
+ function findThumbEl(idx){ return thumbEls.get(idx) || null; }
689
+ function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
690
+ function updateSelectedThumb(){
691
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
692
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
693
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
694
+ }
695
+ function rawCenterThumb(el){
696
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
697
+ }
698
+ async function ensureThumbVisibleCentered(idx){
699
+ for(let k=0; k<40; k++){
700
+ const el = findThumbEl(idx);
701
+ if(el){
702
+ const img = el.querySelector('img');
703
+ if(!img.complete || img.naturalWidth === 0){
704
+ await new Promise(r=>setTimeout(r, 25));
705
+ }else{
706
+ rawCenterThumb(el);
707
+ updatePlayhead();
708
+ return true;
709
+ }
710
+ }else{
711
+ await new Promise(r=>setTimeout(r, 25));
712
+ }
713
+ }
714
+ return false;
715
+ }
716
+ function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
717
+ function updatePlayhead(){
718
+ ensureOverlays();
719
+ const el = findThumbEl(currentIdx);
720
+ const ph = playheadEl();
721
+ if(!el){ ph.style.display='none'; return; }
722
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
723
+ }
724
+ function updatePortionOverlays(){
725
+ ensureOverlays();
726
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
727
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
728
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
729
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
730
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
731
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
732
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
733
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
734
+ }
735
+ function nearestFrameIdxFromClientX(clientX){
736
+ const rect = tlBox.getBoundingClientRect();
737
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
738
+ let bestIdx = currentIdx, bestDist = Infinity;
739
+ for(const [idx, el] of thumbEls.entries()){
740
+ const mid = el.offsetLeft + el.clientWidth/2;
741
+ const d = Math.abs(mid - xIn);
742
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
743
+ }
744
+ return bestIdx;
745
+ }
746
+ function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
747
+ function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
748
+ function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
749
+ async function flushPending(){
750
+ const lst = loadPending(); if(!lst.length) return;
751
+ const kept = [];
752
+ for(const p of lst){
753
+ try{ const r = await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
754
+ catch{ kept.push(p); }
755
+ }
756
+ savePendingList(kept);
757
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
758
+ }
759
+
760
+ // Layout
761
+ function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
762
+ function fitCanvas(){
763
+ const r=player.getBoundingClientRect();
764
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
765
+ canvas.width=Math.round(r.width);
766
+ canvas.height=Math.round(r.height - ctrlH);
767
+ canvas.style.width=r.width+'px';
768
+ canvas.style.height=(r.height - ctrlH)+'px';
769
+ syncTimelineWidth();
770
+ }
771
+ function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
772
+ function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
773
+
774
+ function setMode(m){
775
+ mode=m;
776
+ if(m==='edit'){
777
+ player.pause();
778
+ player.controls = false;
779
+ playerWrap.classList.add('edit-mode');
780
+ modeLabel.textContent='Édition';
781
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
782
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
783
+ canvas.style.pointerEvents='auto';
784
+ rect = rectMap.get(currentIdx) || null; draw();
785
+ }else{
786
+ player.controls = true;
787
+ playerWrap.classList.remove('edit-mode');
788
+ modeLabel.textContent='Lecture';
789
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
790
+ btnSave.style.display='none'; btnClear.style.display='none';
791
+ canvas.style.pointerEvents='none';
792
+ rect=null; draw();
793
+ }
794
+ }
795
+ function draw(){
796
+ ctx.clearRect(0,0,canvas.width,canvas.height);
797
+ if(rect){
798
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
799
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
800
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
801
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
802
+ }
803
+ }
804
+ canvas.addEventListener('mousedown',(e)=>{
805
+ if(mode!=='edit' || !vidName) return;
806
+ dragging=true; const r=canvas.getBoundingClientRect();
807
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
808
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
809
+ });
810
+ canvas.addEventListener('mousemove',(e)=>{
811
+ if(!dragging) return;
812
+ const r=canvas.getBoundingClientRect();
813
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
814
+ });
815
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
816
+ btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
817
+ btnEdit.onclick =()=> setMode('edit');
818
+ btnBack.onclick =()=> setMode('view');
819
+
820
+ // Palette
821
+ palette.querySelectorAll('.swatch').forEach(el=>{
822
+ if(el.dataset.c===color) el.classList.add('sel');
823
+ el.onclick=()=>{
824
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
825
+ el.classList.add('sel'); color=el.dataset.c;
826
+ if(rect){ rect.color=color; draw(); }
827
+ };
828
+ });
829
+
830
+ // === Timeline ===
831
+ async function loadTimelineUrls(){
832
+ timelineUrls = [];
833
+ const stem = vidStem, b = bustToken;
834
+ for(let idx=0; idx<frames; idx++){
835
+ timelineUrls[idx] = P(`/thumbs/f_${stem}_${idx}.jpg?b=${b}`);
836
+ }
837
+ tlProgressFill.style.width='0%';
838
+ }
839
+
840
+ async function renderTimeline(centerIdx){
841
+ if(!vidName) return;
842
+ loadingInd.style.display='block';
843
+ if(timelineUrls.length===0) await loadTimelineUrls();
844
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
845
+
846
+ if(maskedOnlyMode){
847
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
848
+ for(const i of idxs){ addThumb(i,'append'); }
849
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
850
+ return;
851
+ }
852
+
853
+ if(portionStart!=null && portionEnd!=null){
854
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
855
+ for(let i=s;i<e;i++){ addThumb(i,'append'); }
856
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
857
+ return;
858
+ }
859
+
860
+ await loadWindow(centerIdx ?? currentIdx);
861
+ loadingInd.style.display='none';
862
+ }
863
+
864
+ async function loadWindow(centerIdx){
865
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
866
+ const rngStart = (viewRangeStart ?? 0);
867
+ const rngEnd = (viewRangeEnd ?? frames);
868
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
869
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
870
+ const end = Math.min(rngEnd, start + chunkSize);
871
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
872
+ timelineStart = start; timelineEnd = end;
873
+ setTimeout(async ()=>{
874
+ syncTimelineWidth();
875
+ updateSelectedThumb();
876
+ await ensureThumbVisibleCentered(currentIdx);
877
+ updatePortionOverlays();
878
+ },0);
879
+ }
880
+
881
+ function addThumb(idx, place='append'){
882
+ if(thumbEls.has(idx)) return;
883
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
884
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
885
+ const img=new Image(); img.title='frame '+(idx+1);
886
+ img.src=timelineUrls[idx];
887
+ img.onerror = () => {
888
+ const fallback = P(`/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`);
889
+ img.onerror = null;
890
+ img.src = fallback;
891
+ img.onload = () => {
892
+ const nu = P(`/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`);
893
+ timelineUrls[idx] = nu;
894
+ img.src = nu;
895
+ img.onload = null;
896
+ };
897
+ };
898
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
899
+ img.onclick=async ()=>{
900
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
901
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
902
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
903
+ };
904
+ wrap.appendChild(img);
905
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
906
+ wrap.appendChild(label);
907
+ if(place==='append'){ tlBox.appendChild(wrap); }
908
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
909
+ else{ tlBox.appendChild(wrap); }
910
+ thumbEls.set(idx, wrap);
911
+ }
912
+
913
+ // Scroll chunk (mode normal uniquement)
914
+ tlBox.addEventListener('scroll', ()=>{
915
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
916
+ updatePlayhead(); updatePortionOverlays();
917
+ return;
918
+ }
919
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
920
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
921
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
922
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
923
+ timelineEnd = newEnd;
924
+ }
925
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
926
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
927
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
928
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
929
+ timelineStart = newStart;
930
+ }
931
+ updatePlayhead(); updatePortionOverlays();
932
+ });
933
+
934
+ // Isoler & Boucle
935
+ isolerBoucle.onclick = async ()=>{
936
+ const start = parseInt(goFrame.value || '1',10) - 1;
937
+ const end = parseInt(endPortion.value || '',10);
938
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
939
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
940
+ portionStart = start; portionEnd = end;
941
+ viewRangeStart = start; viewRangeEnd = end;
942
+ player.pause(); isPaused = true;
943
+ currentIdx = start; player.currentTime = idxToSec(start);
944
+ await renderTimeline(currentIdx);
945
+ resetFull.style.display = 'inline-block';
946
+ startLoop(); updatePortionOverlays();
947
+ };
948
+ function startLoop(){
949
+ if(loopInterval) clearInterval(loopInterval);
950
+ if(portionEnd != null){
951
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
952
+ }
953
+ }
954
+ resetFull.onclick = async ()=>{
955
+ portionStart = null; portionEnd = null;
956
+ viewRangeStart = 0; viewRangeEnd = frames;
957
+ goFrame.value = 1; endPortion.value = '';
958
+ player.pause(); isPaused = true;
959
+ await renderTimeline(currentIdx);
960
+ resetFull.style.display='none';
961
+ clearInterval(loopInterval); updatePortionOverlays();
962
+ };
963
+
964
+ // Drag IN/OUT
965
+ function attachHandleDrag(handle, which){
966
+ let draggingH=false;
967
+ function onMove(e){
968
+ if(!draggingH) return;
969
+ const idx = nearestFrameIdxFromClientX(e.clientX);
970
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
971
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
972
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
973
+ updatePortionOverlays();
974
+ }
975
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
976
+ window.addEventListener('mousemove', onMove);
977
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
978
+ }
979
+ ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
980
+
981
+ // Progress popup
982
+ async function showProgress(vidStem){
983
+ popup.style.display = 'block';
984
+ const interval = setInterval(async () => {
985
+ const r = await fetch(P('/progress/' + vidStem));
986
+ const d = await r.json();
987
+ tlProgressFill.style.width = d.percent + '%';
988
+ popupProgressFill.style.width = d.percent + '%';
989
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
990
+ if(d.done){
991
+ clearInterval(interval);
992
+ popup.style.display = 'none';
993
+ await renderTimeline(currentIdx);
994
+ }
995
+ }, 800);
996
+ }
997
+
998
+ // Meta & boot
999
+ async function loadVideoAndMeta() {
1000
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1001
+ vidStem = fileStem(vidName); bustToken = Date.now();
1002
+ const bust = Date.now();
1003
+ srcEl.src = P('/data/'+encodeURIComponent(vidName)+'?t='+bust);
1004
+ player.setAttribute('poster', P('/poster/'+encodeURIComponent(vidName)+'?t='+bust));
1005
+ player.load();
1006
+ fitCanvas();
1007
+ statusEl.textContent = 'Chargement vidéo…';
1008
+ try{
1009
+ const r=await fetch(P('/meta/'+encodeURIComponent(vidName)));
1010
+ if(r.ok){
1011
+ const m=await r.json();
1012
+ fps=m.fps||30; frames=m.frames||0;
1013
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
1014
+ viewRangeStart = 0; viewRangeEnd = frames;
1015
+ await loadTimelineUrls();
1016
+ await loadMasks();
1017
+ currentIdx = 0; player.currentTime = 0;
1018
+ await renderTimeline(0);
1019
+ showProgress(vidStem);
1020
+ }else{
1021
+ statusEl.textContent = 'Erreur meta';
1022
+ }
1023
+ }catch(err){
1024
+ statusEl.textContent = 'Erreur réseau meta';
1025
+ }
1026
+ }
1027
+ player.addEventListener('loadedmetadata', async ()=>{
1028
+ fitCanvas();
1029
+ if(!frames || frames<=0){
1030
+ try{ const r=await fetch(P('/meta/'+encodeURIComponent(vidName))); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
1031
+ }
1032
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
1033
+ });
1034
+ window.addEventListener('resize', ()=>{ fitCanvas(); });
1035
+ player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1036
+ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1037
+ player.addEventListener('timeupdate', ()=>{
1038
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1039
+ currentIdx=timeToIdx(player.currentTime);
1040
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1041
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
1042
+ if(followMode && !isPaused){
1043
+ const now = Date.now();
1044
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1045
+ }
1046
+ });
1047
+ goFrame.addEventListener('change', async ()=>{
1048
+ if(!vidName) return;
1049
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
1050
+ player.pause(); isPaused = true;
1051
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1052
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1053
+ await renderTimeline(currentIdx);
1054
+ await ensureThumbVisibleCentered(currentIdx);
1055
+ });
1056
+
1057
+ // Follow / Filter / Zoom / Goto
1058
+ btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1059
+ btnFilterMasked.onclick = async ()=>{
1060
+ maskedOnlyMode = !maskedOnlyMode;
1061
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1062
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1063
+ await renderTimeline(currentIdx);
1064
+ await ensureThumbVisibleCentered(currentIdx);
1065
+ };
1066
+ zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1067
+ async function gotoFrameNum(){
1068
+ const v = parseInt(gotoInput.value||'',10);
1069
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1070
+ player.pause(); isPaused = true;
1071
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1072
+ goFrame.value = v;
1073
+ await renderTimeline(currentIdx);
1074
+ await ensureThumbVisibleCentered(currentIdx);
1075
+ }
1076
+ gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1077
+ gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1078
+
1079
+ // Drag & drop upload
1080
+ const uploadZone = document.getElementById('uploadForm');
1081
+ uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1082
+ uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1083
+ uploadZone.addEventListener('drop', (e) => {
1084
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1085
+ const file = e.dataTransfer.files[0];
1086
+ if(file && file.type.startsWith('video/')){
1087
+ const fd = new FormData(); fd.append('file', file);
1088
+ fetch(P('/upload?redirect=1'), {method: 'POST', body: fd}).then(() => location.reload());
1089
+ }
1090
+ });
1091
+ // Export placeholder
1092
+ document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1093
+
1094
+ // Fichiers & masques
1095
+ async function loadFiles(){
1096
+ const r=await fetch(P('/files')); const d=await r.json();
1097
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1098
+ fileList.innerHTML='';
1099
+ d.items.forEach(name=>{
1100
+ const li=document.createElement('li');
1101
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1102
+ delBtn.onclick=async()=>{
1103
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1104
+ await fetch(P('/delete/'+encodeURIComponent(name)),{method:'DELETE'});
1105
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1106
+ };
1107
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1108
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1109
+ });
1110
+ }
1111
+ async function loadMasks(){
1112
+ loadingInd.style.display='block';
1113
+ const box=document.getElementById('maskList');
1114
+ const r=await fetch(P('/mask/'+encodeURIComponent(vidName)));
1115
+ const d=await r.json();
1116
+ masks=d.masks||[];
1117
+ maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1118
+ rectMap.clear();
1119
+ masks.forEach(m=>{
1120
+ if(m.shape==='rect'){
1121
+ const [x1,y1,x2,y2] = m.points;
1122
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1123
+ rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1124
+ }
1125
+ });
1126
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1127
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1128
+ box.innerHTML='';
1129
+ const ul=document.createElement('ul'); ul.className='clean';
1130
+ masks.forEach(m=>{
1131
+ const li=document.createElement('li');
1132
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1133
+ const t=(m.time_s||0).toFixed(2);
1134
+ const col=m.color||'#10b981';
1135
+ const label=m.note||(`frame ${fr}`);
1136
+ 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`;
1137
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1138
+ renameBtn.onclick=async()=>{
1139
+ const nv=prompt('Nouveau nom du masque :', label);
1140
+ if(nv===null) return;
1141
+ const rr=await fetch(P('/mask/rename'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1142
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1143
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1144
+ }
1145
+ };
1146
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1147
+ delMaskBtn.onclick=async()=>{
1148
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1149
+ const rr=await fetch(P('/mask/delete'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1150
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1151
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1152
+ }
1153
+ };
1154
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1155
+ });
1156
+ box.appendChild(ul);
1157
+ loadingInd.style.display='none';
1158
+ }
1159
+
1160
+ // Save mask (+ cache)
1161
+ btnSave.onclick = async ()=>{
1162
+ if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1163
+ const defaultName = `frame ${currentIdx+1}`;
1164
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1165
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1166
+ const x=Math.min(rect.x1,rect.x2)/normW;
1167
+ const y=Math.min(rect.y1,rect.y2)/normH;
1168
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1169
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1170
+ 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};
1171
+ addPending(payload);
1172
+ try{
1173
+ const r=await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1174
+ if(r.ok){
1175
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1176
+ savePendingList(lst);
1177
+ rectMap.set(currentIdx,{...rect});
1178
+ await loadMasks(); await renderTimeline(currentIdx);
1179
+ showToast('Masque enregistré ✅');
1180
+ } else {
1181
+ const txt = await r.text();
1182
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1183
+ }
1184
+ }catch(e){
1185
+ alert('Erreur réseau lors de l’enregistrement du masque.');
1186
+ }
1187
+ };
1188
+
1189
+ // Boot
1190
+ async function boot(){
1191
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1192
+ await loadFiles();
1193
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1194
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1195
+ }
1196
+ boot();
1197
+
1198
+ // Hide controls in edit-mode
1199
+ const style = document.createElement('style');
1200
+ style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1201
+ document.head.appendChild(style);
1202
+ </script>
1203
+ </html>
1204
+ """
1205
 
1206
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1207
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):