FABLESLIP commited on
Commit
fd84955
·
verified ·
1 Parent(s): d1b23dc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +803 -423
app.py CHANGED
@@ -1,15 +1,5 @@
1
  # app.py — Video Editor API (v0.8.1)
2
- # v0.8.1 (2025-08-24, option 3 Hub):
3
- # - Espace unique FastAPI + UI HTML/JS
4
- # - Proxy /p/* conservé (POINTER_URL / FALLBACK_BASE)
5
- # - Génération de vignettes (FFmpeg prioritaire, OpenCV fallback)
6
- # - Timeline scrollable par fenêtres + centrage fiable + filtre ⭐
7
- # - Masques rectangles par frame (multi‑masques côté UI)
8
- # - Undo/Redo (UI) + auto‑save localStorage (UI)
9
- # - Endpoints warm‑up modèles Hugging Face (séquentiel) + statut
10
- # - Stubs IA: /estimate, /progress_ia, /mask/ai, /inpaint
11
- # NOTE: Impl SAM2/DiffuEraser viendra ensuite (cache déjà prêt via warm‑up)
12
-
13
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
14
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
15
  from fastapi.staticfiles import StaticFiles
@@ -21,17 +11,17 @@ import subprocess
21
  import shutil as _shutil
22
  import os
23
  import httpx
24
-
25
- # ---------- Pointeur de backend (inchangé) ----------
 
26
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
27
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
28
  _backend_url_cache = {"url": None, "ts": 0.0}
29
-
30
  def get_backend_base() -> str:
31
  try:
32
  if POINTER_URL:
33
  now = time.time()
34
- need_refresh = (not _backend_url_cache["url"]) or (now - _backend_url_cache["ts"] > 30)
35
  if need_refresh:
36
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
37
  url = (r.text or "").strip()
@@ -44,52 +34,44 @@ def get_backend_base() -> str:
44
  return FALLBACK_BASE
45
  except Exception:
46
  return FALLBACK_BASE
47
-
48
- # ---------- App & Dossiers ----------
49
  print("[BOOT] Video Editor API starting…")
50
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
51
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
52
-
53
  app = FastAPI(title="Video Editor API", version="0.8.1")
54
-
55
  DATA_DIR = Path("/app/data")
56
  THUMB_DIR = DATA_DIR / "_thumbs"
57
  MASK_DIR = DATA_DIR / "_masks"
58
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
59
  p.mkdir(parents=True, exist_ok=True)
60
-
61
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
62
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
63
-
64
- # ---------- États globaux ----------
65
- progress_data: Dict[str, Dict[str, Any]] = {} # vid_stem -> {percent, logs, done}
66
-
67
- # Warm‑up modèles (séquentiel)
68
- warmup_state: Dict[str, Any] = {
69
- "running": False,
70
- "index": 0,
71
- "total": 0,
72
- "current": "",
73
- "percent": 0,
74
- "logs": [],
75
- "done": False,
76
- "error": ""
77
- }
78
-
79
- # ---------- Helpers vidéo ----------
80
-
 
81
  def _is_video(p: Path) -> bool:
82
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
83
-
84
  def _safe_name(name: str) -> str:
85
  return Path(name).name.replace(" ", "_")
86
-
87
  def _has_ffmpeg() -> bool:
88
  return _shutil.which("ffmpeg") is not None
89
-
90
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
91
  return f"scale=min(iw\\,{max_w}):-2"
92
-
93
  def _meta(video: Path):
94
  cap = cv2.VideoCapture(str(video))
95
  if not cap.isOpened():
@@ -102,7 +84,6 @@ def _meta(video: Path):
102
  cap.release()
103
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
104
  return {"frames": frames, "fps": fps, "w": w, "h": h}
105
-
106
  def _frame_jpg(video: Path, idx: int) -> Path:
107
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
108
  if out.exists():
@@ -148,7 +129,6 @@ def _frame_jpg(video: Path, idx: int) -> Path:
148
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
149
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
150
  return out
151
-
152
  def _poster(video: Path) -> Path:
153
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
154
  if out.exists():
@@ -163,10 +143,8 @@ def _poster(video: Path) -> Path:
163
  except Exception as e:
164
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
165
  return out
166
-
167
  def _mask_file(vid: str) -> Path:
168
  return MASK_DIR / f"{Path(vid).name}.json"
169
-
170
  def _load_masks(vid: str) -> Dict[str, Any]:
171
  f = _mask_file(vid)
172
  if f.exists():
@@ -175,12 +153,8 @@ def _load_masks(vid: str) -> Dict[str, Any]:
175
  except Exception as e:
176
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
177
  return {"video": vid, "masks": []}
178
-
179
  def _save_masks(vid: str, data: Dict[str, Any]):
180
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
181
-
182
- # ----------- Génération thumbs en arrière‑plan -----------
183
-
184
  def _gen_thumbs_background(video: Path, vid_stem: str):
185
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
186
  try:
@@ -194,7 +168,6 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
194
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
195
  progress_data[vid_stem]['done'] = True
196
  return
197
- # Nettoyer anciennes thumbs du même stem
198
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
199
  f.unlink(missing_ok=True)
200
  if _has_ffmpeg():
@@ -258,47 +231,160 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
258
  except Exception as e:
259
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
260
  progress_data[vid_stem]['done'] = True
261
-
262
- # ---------- API de base ----------
263
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  @app.get("/", tags=["meta"])
265
  def root():
266
- return {
267
- "ok": True,
268
- "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", "/estimate", "/progress_ia", "/mask/ai", "/inpaint"]
269
- }
270
-
271
  @app.get("/health", tags=["meta"])
272
  def health():
273
  return {"status": "ok"}
274
-
275
  @app.get("/_env", tags=["meta"])
276
  def env_info():
277
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
278
-
279
- # --- Proxy navigateur -> backend ---
280
- @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
281
- async def proxy_all(full_path: str, request: Request):
282
- base = get_backend_base().rstrip("/")
283
- target = f"{base}/{full_path}"
284
- qs = request.url.query
285
- if qs:
286
- target = f"{target}?{qs}"
287
- body = await request.body()
288
- headers = dict(request.headers)
289
- headers.pop("host", None)
290
- async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
291
- r = await client.request(request.method, target, headers=headers, content=body)
292
- drop = {"content-encoding","transfer-encoding","connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailers","upgrade"}
293
- out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
294
- return Response(content=r.content, status_code=r.status_code, headers=out_headers)
295
-
296
- # --- IO fichiers ---
297
  @app.get("/files", tags=["io"])
298
  def files():
299
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
300
  return {"count": len(items), "items": items}
301
-
302
  @app.get("/meta/{vid}", tags=["io"])
303
  def video_meta(vid: str):
304
  v = DATA_DIR / vid
@@ -308,7 +394,6 @@ def video_meta(vid: str):
308
  if not m:
309
  raise HTTPException(500, "Métadonnées indisponibles")
310
  return m
311
-
312
  @app.post("/upload", tags=["io"])
313
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
314
  ext = (Path(file.filename).suffix or ".mp4").lower()
@@ -329,11 +414,9 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
329
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
330
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
331
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
332
-
333
  @app.get("/progress/{vid_stem}", tags=["io"])
334
  def progress(vid_stem: str):
335
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
336
-
337
  @app.delete("/delete/{vid}", tags=["io"])
338
  def delete_video(vid: str):
339
  v = DATA_DIR / vid
@@ -346,7 +429,6 @@ def delete_video(vid: str):
346
  v.unlink(missing_ok=True)
347
  print(f"[DELETE] {vid}", file=sys.stdout)
348
  return {"deleted": vid}
349
-
350
  @app.get("/frame_idx", tags=["io"])
351
  def frame_idx(vid: str, idx: int):
352
  v = DATA_DIR / vid
@@ -362,7 +444,6 @@ def frame_idx(vid: str, idx: int):
362
  except Exception as e:
363
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
364
  raise HTTPException(500, "Frame error")
365
-
366
  @app.get("/poster/{vid}", tags=["io"])
367
  def poster(vid: str):
368
  v = DATA_DIR / vid
@@ -372,7 +453,6 @@ def poster(vid: str):
372
  if p.exists():
373
  return FileResponse(str(p), media_type="image/jpeg")
374
  raise HTTPException(404, "Poster introuvable")
375
-
376
  @app.get("/window/{vid}", tags=["io"])
377
  def window(vid: str, center: int = 0, count: int = 21):
378
  v = DATA_DIR / vid
@@ -400,211 +480,80 @@ def window(vid: str, center: int = 0, count: int = 21):
400
  items.append({"i": i, "idx": idx, "url": url})
401
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
402
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
403
-
404
- # ---------- Masques ----------
405
-
406
- @app.post("/mask", tags=["mask"])
407
- async def save_mask(payload: Dict[str, Any] = Body(...)):
408
- vid = payload.get("vid")
409
- if not vid:
410
- raise HTTPException(400, "vid manquant")
411
- points = payload.get("points") or []
412
- if not (isinstance(points, list) and len(points) == 4):
413
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
414
- data = _load_masks(vid)
415
- m = {
416
- "id": uuid.uuid4().hex[:10],
417
- "time_s": float(payload.get("time_s") or 0.0),
418
- "frame_idx": int(payload.get("frame_idx") or 0),
419
- "shape": "rect",
420
- "points": [float(x) for x in points],
421
- "color": payload.get("color") or "#10b981",
422
- "note": (payload.get("note") or "").strip()
423
- }
424
- data.setdefault("masks", []).append(m)
425
- _save_masks(vid, data)
426
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
427
- return {"saved": True, "mask": m}
428
-
429
- @app.get("/mask/{vid}", tags=["mask"])
430
- def list_masks(vid: str):
431
- return _load_masks(vid)
432
-
433
- @app.post("/mask/rename", tags=["mask"])
434
- async def rename_mask(payload: Dict[str, Any] = Body(...)):
435
- vid = payload.get("vid")
436
- mid = payload.get("id")
437
- new_note = (payload.get("note") or "").strip()
438
- if not vid or not mid:
439
- raise HTTPException(400, "vid et id requis")
440
- data = _load_masks(vid)
441
- for m in data.get("masks", []):
442
- if m.get("id") == mid:
443
- m["note"] = new_note
444
- _save_masks(vid, data)
445
- return {"ok": True}
446
- raise HTTPException(404, "Masque introuvable")
447
-
448
- @app.post("/mask/delete", tags=["mask"])
449
- async def delete_mask(payload: Dict[str, Any] = Body(...)):
450
- vid = payload.get("vid")
451
- mid = payload.get("id")
452
- if not vid or not mid:
453
- raise HTTPException(400, "vid et id requis")
454
- data = _load_masks(vid)
455
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
456
- _save_masks(vid, data)
457
- return {"ok": True}
458
-
459
- # ---------- Stubs IA (à compléter) ----------
460
-
461
- @app.get("/estimate", tags=["ai"])
462
- def estimate(vid: str, masks_count: int):
463
- try:
464
- masks_count = int(masks_count)
465
- except Exception:
466
- masks_count = 1
467
- # estimation simple placeholder
468
- return {"time_min": max(1, masks_count * 2), "vram_gb": 4}
469
-
470
- @app.get("/progress_ia", tags=["ai"])
471
- def progress_ia(vid: str):
472
- # placeholder
473
- return {"percent": 0, "log": "En attente GPU/implémentation…"}
474
-
475
- @app.post("/mask/ai", tags=["ai"])
476
- async def mask_ai(payload: Dict[str, Any] = Body(...)):
477
- # placeholder SAM2
478
- raise HTTPException(503, "Activer GPU et implémenter SAM2 dans une version ultérieure.")
479
-
480
- @app.post("/inpaint", tags=["ai"])
481
- async def inpaint(payload: Dict[str, Any] = Body(...)):
482
- # placeholder DiffuEraser
483
- raise HTTPException(503, "Activer GPU et implémenter DiffuEraser dans une version ultérieure.")
484
-
485
- # ---------- Warm‑up modèles (Hugging Face Hub) ----------
486
-
487
- # Liste par défaut de dépôts à pré‑télécharger (séquentiel)
488
- DEFAULT_MODEL_REPOS: List[str] = [
489
- "facebook/sam2-hiera-large",
490
- "lixiaowen/diffuEraser",
491
- # Optionnels pour la suite (commenter/ajouter selon besoins)
492
- # "runwayml/stable-diffusion-v1-5",
493
- # "stabilityai/sd-vae-ft-mse",
494
- ]
495
-
496
- # Le warm‑up évite de télécharger au boot. Nécessite huggingface_hub installé + HF_TOKEN (si requis par le repo)
497
-
498
- def _do_warmup(models: List[str]):
499
- from huggingface_hub import snapshot_download, HfHubHTTPError
500
- st = warmup_state
501
- st.update({"running": True, "index": 0, "total": len(models), "current": "", "percent": 0, "logs": [], "done": False, "error": ""})
502
- cache_home = os.environ.get("HF_HOME", "/root/.cache/huggingface")
503
- try:
504
- for i, repo in enumerate(models, start=1):
505
- st["index"] = i
506
- st["current"] = repo
507
- st["percent"] = int(((i-1) / max(1, st["total"])) * 100)
508
- st["logs"].append(f"↓ Téléchargement {i}/{st['total']} : {repo}")
509
- try:
510
- snapshot_download(repo_id=repo, local_dir=Path(cache_home) / repo.replace("/", "__"), local_dir_use_symlinks=False)
511
- st["logs"].append(f"✔️ OK : {repo}")
512
- except HfHubHTTPError as e:
513
- st["logs"].append(f"⚠️ Erreur HF : {repo} — {e}")
514
- except Exception as e:
515
- st["logs"].append(f"⚠️ Erreur : {repo} — {e}")
516
- st["percent"] = 100
517
- st["done"] = True
518
- st["running"] = False
519
- st["logs"].append("✅ Warm‑up terminé.")
520
- except Exception as e:
521
- st["error"] = str(e)
522
- st["running"] = False
523
- st["done"] = True
524
- st["logs"].append(f"❌ Warm‑up interrompu : {e}")
525
-
526
- @app.post("/warmup/start", tags=["warmup"])
527
- async def warmup_start(payload: Dict[str, Any] = Body(default={})): # payload optionnel: {"models": [..]}
528
- if warmup_state.get("running"):
529
- return {"ok": False, "msg": "Déjà en cours"}
530
- models = payload.get("models") if isinstance(payload, dict) else None
531
- if not models:
532
- models = DEFAULT_MODEL_REPOS
533
- t = threading.Thread(target=_do_warmup, args=(list(models),), daemon=True)
534
- t.start()
535
- return {"ok": True, "msg": "Warm‑up démarré", "models": models}
536
-
537
- @app.get("/warmup/status", tags=["warmup"])
538
- async def warmup_status():
539
- return warmup_state
540
-
541
- # ---------- UI ----------
542
-
543
  HTML_TEMPLATE = r"""
544
  <!doctype html>
545
  <html lang="fr"><meta charset="utf-8">
546
  <title>Video Editor</title>
547
- <style>
548
- :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb}
549
- *{box-sizing:border-box}
550
- body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
551
- h1{margin:0 0 8px 0}
552
- .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
553
- .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
554
- .muted{color:var(--muted);font-size:13px}
555
- .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
556
- .viewer{max-width:1024px;margin:0 auto; position:relative}
557
- .player-wrap{position:relative; padding-bottom: var(--controlsH);}
558
- video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
559
- #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
560
- .timeline-container{margin-top:10px}
561
- .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%}
562
- .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
563
- .thumb:hover{transform:scale(1.05)}
564
- .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
565
- .thumb img.sel{border-color:var(--active-border)}
566
- .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
567
- .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}
568
- .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
569
- .timeline.filter-masked .thumb:not(.hasmask){display:none}
570
- .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
571
- .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
572
- .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
573
- .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
574
- .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}
575
- .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
576
- ul.clean{list-style:none;padding-left:0;margin:6px 0}
577
- ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
578
- .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
579
- .rename-btn:hover{color:#2563eb}
580
- .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
581
- .delete-btn:hover{color:#b91c1c}
582
- #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
583
- #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
584
- #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
585
- #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}
586
- #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
587
- #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
588
- #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
589
- #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
590
- #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
591
- .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
592
- .inline-bar{display:inline-flex;align-items:center;gap:8px}
593
- .bar{width:140px;height:8px;background:#f3f4f6;border-radius:4px;overflow:hidden}
594
- .fill{height:100%;width:0;background:#2563eb}
595
- details.hint{background:#fff7ed;border:1px dashed #f59e0b;border-radius:8px;padding:8px}
 
 
 
 
 
 
 
 
 
 
596
  </style>
597
  <h1>🎬 Video Editor</h1>
598
  <div class="topbar card">
599
- <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
600
- <strong>Charger une vidéo :</strong>
601
- <input type="file" name="file" accept="video/*" required>
602
- <button class="btn" type="submit">Uploader</button>
603
- </form>
604
- <button id="btnWarmup" class="btn" title="Pré‑télécharger les modèles (Hub)">Warm‑up modèles (Hub)</button>
605
- <span class="inline-bar muted">Warm‑up: <span id="wuLabel">—</span><span class="bar"><span id="wuFill" class="fill"></span></span></span>
606
- <span class="muted" id="msg">__MSG__</span>
607
- <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
608
  </div>
609
  <div class="layout">
610
  <div>
@@ -646,10 +595,10 @@ HTML_TEMPLATE = r"""
646
  <div class="row" style="margin-top:6px">
647
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
648
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
649
- <button id="btnSave" class="btn" style="display:none" title="Enregistrer les masques de cette frame">💾 Enregistrer masques</button>
650
- <button id="btnClear" class="btn" style="display:none" title="Effacer les sélections non sauvegardées">🧽 Effacer sélection</button>
651
- <button id="btnUndo" class="btn" style="display:none" title="Annuler le dernier rectangle (non sauvegardé)">↩️ Undo</button>
652
- <button id="btnRedo" class="btn" style="display:none" title="Refaire le dernier undo">↪️ Redo</button>
653
  </div>
654
  <div style="margin-top:10px">
655
  <div class="muted">Couleur</div>
@@ -663,19 +612,21 @@ HTML_TEMPLATE = r"""
663
  </div>
664
  <div style="margin-top:12px">
665
  <details open>
666
- <summary><strong>Masques enregistrés</strong></summary>
667
  <div id="maskList" class="muted">—</div>
668
- <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques (inpainting IA)">Exporter vidéo modifiée</button>
 
 
 
669
  </details>
 
 
670
  <div style="margin-top:6px">
671
  <strong>Vidéos disponibles</strong>
672
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
673
  </div>
674
  </div>
675
- <details class="hint" style="margin-top:12px" open>
676
- <summary>🟡 Tutoriel (cliquer pour masquer)</summary>
677
- 1) Upload une vidéo depuis ton ordinateur. 2) Clique ✏️ Éditer, dessine un ou plusieurs rectangles (couleur au choix). 3) 💾 Enregistrer masques. 4) Plus tard : IA Preview/Export (activer GPU). 5) Warm‑up pré‑charge les modèles depuis le Hub.
678
- </details>
679
  </div>
680
  </div>
681
  <div id="popup">
@@ -684,12 +635,18 @@ HTML_TEMPLATE = r"""
684
  <div id="popup-logs"></div>
685
  </div>
686
  <div id="toast"></div>
 
 
 
 
 
687
  <script>
688
  const serverVid = "__VID__";
689
  const serverMsg = "__MSG__";
690
  document.getElementById('msg').textContent = serverMsg;
691
-
692
- // --------- Éléments ---------
 
693
  const statusEl = document.getElementById('status');
694
  const player = document.getElementById('player');
695
  const srcEl = document.getElementById('vidsrc');
@@ -700,8 +657,6 @@ const btnEdit = document.getElementById('btnEdit');
700
  const btnBack = document.getElementById('btnBack');
701
  const btnSave = document.getElementById('btnSave');
702
  const btnClear= document.getElementById('btnClear');
703
- const btnUndo = document.getElementById('btnUndo');
704
- const btnRedo = document.getElementById('btnRedo');
705
  const posInfo = document.getElementById('posInfo');
706
  const goFrame = document.getElementById('goFrame');
707
  const palette = document.getElementById('palette');
@@ -725,11 +680,16 @@ const hud = document.getElementById('hud');
725
  const toastWrap = document.getElementById('toast');
726
  const gotoInput = document.getElementById('gotoInput');
727
  const gotoBtn = document.getElementById('gotoBtn');
728
- const btnWarmup = document.getElementById('btnWarmup');
729
- const wuLabel = document.getElementById('wuLabel');
730
- const wuFill = document.getElementById('wuFill');
731
-
732
- // --------- State ---------
 
 
 
 
 
733
  let vidName = serverVid || '';
734
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
735
  let vidStem = '';
@@ -737,7 +697,12 @@ let bustToken = Date.now();
737
  let fps = 30, frames = 0;
738
  let currentIdx = 0;
739
  let mode = 'view';
 
 
740
  let color = '#10b981';
 
 
 
741
  let masks = [];
742
  let maskedSet = new Set();
743
  let timelineUrls = [];
@@ -745,6 +710,7 @@ let portionStart = null;
745
  let portionEnd = null;
746
  let loopInterval = null;
747
  let chunkSize = 50;
 
748
  let viewRangeStart = 0, viewRangeEnd = 0;
749
  const scrollThreshold = 100;
750
  let followMode = false;
@@ -752,151 +718,565 @@ let isPaused = true;
752
  let thumbEls = new Map();
753
  let lastCenterMs = 0;
754
  const CENTER_THROTTLE_MS = 150;
 
755
  let maskedOnlyMode = false;
756
-
757
- // Multi‑rectangles non sauvegardés par frame
758
- const rectStacks = new Map(); // frameIdx -> {list: Rect[], undo: Rect[], redo: Rect[]}
759
- let dragging=false, sx=0, sy=0; let liveRect=null;
760
-
761
- // Auto‑save localStorage
762
- const PENDING_KEY = 've_pending_masks_v2';
763
-
764
- // --------- Utils ---------
765
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
766
- function ensureStack(i){ if(!rectStacks.has(i)) rectStacks.set(i,{list:[],undo:[],redo:[]}); return rectStacks.get(i); }
767
- function allUnsavedFor(i){ return ensureStack(i).list; }
768
- function pushRect(i, r){ const s=ensureStack(i); s.list.push(r); s.undo.push(r); s.redo.length=0; }
769
- function doUndo(i){ const s=ensureStack(i); const r=s.list.pop(); if(r){ s.redo.push(r); draw(); } }
770
- function doRedo(i){ const s=ensureStack(i); const r=s.redo.pop(); if(r){ s.list.push(r); draw(); } }
771
-
772
- function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
773
- function updateSelectedThumb(){ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); }); const el=findThumbEl(currentIdx); if(!el) return; const img=el.querySelector('img'); img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
 
 
774
  function findThumbEl(idx){ return thumbEls.get(idx) || null; }
775
- function rawCenterThumb(el){ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2); }
776
- async function ensureThumbVisibleCentered(idx){ for(let k=0; k<40; k++){ const el=findThumbEl(idx); if(el){ const img=el.querySelector('img'); if(!img.complete || img.naturalWidth===0){ await new Promise(r=>setTimeout(r,25)); } else { rawCenterThumb(el); updatePlayhead(); return true; } } else { await new Promise(r=>setTimeout(r,25)); } } return false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
778
- function updatePlayhead(){ const el=findThumbEl(currentIdx); const phId='playhead'; let ph=document.getElementById(phId); if(!ph){ ph=document.createElement('div'); ph.id=phId; ph.className='playhead'; tlBox.appendChild(ph); } if(!el){ ph.style.display='none'; return; } ph.style.display='block'; ph.style.position='absolute'; ph.style.top='0'; ph.style.bottom='0'; ph.style.width='2px'; ph.style.background='var(--active-border)'; ph.style.opacity='.9'; ph.style.left=(el.offsetLeft + el.clientWidth/2)+'px'; }
779
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
781
- 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'; syncTimelineWidth(); draw(); }
 
 
 
 
 
 
 
 
782
  function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
783
  function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
784
-
785
- function setMode(m){ mode=m; if(m==='edit'){ player.pause(); isPaused = true; player.controls=false; modeLabel.textContent='Édition'; btnEdit.style.display='none'; [btnBack,btnSave,btnClear,btnUndo,btnRedo].forEach(b=>b.style.display='inline-block'); canvas.style.pointerEvents='auto'; draw(); } else { player.controls=true; modeLabel.textContent='Lecture'; btnEdit.style.display='inline-block'; [btnBack,btnSave,btnClear,btnUndo,btnRedo].forEach(b=>b.style.display='none'); canvas.style.pointerEvents='none'; liveRect=null; draw(); } }
786
-
787
- function normRect(r){ const normW=canvas.clientWidth, normH=canvas.clientHeight; const x=Math.min(r.x1,r.x2)/normW; const y=Math.min(r.y1,r.y2)/normH; const w=Math.abs(r.x2-r.x1)/normW; const h=Math.abs(r.y2-r.y1)/normH; return [x,y,x+w,y+h]; }
788
- function fromNormRect(n){ const [x1,y1,x2,y2]=n; const W=canvas.clientWidth,H=canvas.clientHeight; return {x1:x1*W,y1:y1*H,x2:x2*W,y2:y2*H,color:color}; }
789
-
790
- function draw(){ ctx.clearRect(0,0,canvas.width,canvas.height); if(mode!=='edit') return; const L = allUnsavedFor(currentIdx).slice(); if(liveRect) L.push(liveRect); for(const r of L){ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2); const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1); ctx.strokeStyle=r.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(r.color||color)+'28'; ctx.fillRect(x,y,w,h); } }
791
-
792
- // Dessin
793
- canvas.addEventListener('mousedown',(e)=>{ if(mode!=='edit' || !vidName) return; const r=canvas.getBoundingClientRect(); dragging=true; sx=e.clientX-r.left; sy=e.clientY-r.top; liveRect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw(); });
794
- canvas.addEventListener('mousemove',(e)=>{ if(!dragging) return; const r=canvas.getBoundingClientRect(); liveRect.x2=e.clientX-r.left; liveRect.y2=e.clientY-r.top; draw(); });
795
- ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ if(!dragging) return; dragging=false; if(liveRect && Math.abs(liveRect.x2-liveRect.x1)>4 && Math.abs(liveRect.y2-liveRect.y1)>4){ pushRect(currentIdx,{...liveRect}); } liveRect=null; draw(); }));
796
-
797
- btnClear.onclick=()=>{ const s=ensureStack(currentIdx); s.list.length=0; s.undo.length=0; s.redo.length=0; liveRect=null; draw(); };
798
- btnUndo.onclick =()=> doUndo(currentIdx);
799
- btnRedo.onclick =()=> doRedo(currentIdx);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  btnEdit.onclick =()=> setMode('edit');
801
  btnBack.onclick =()=> setMode('view');
802
-
803
- // Palette
804
- 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(liveRect){ liveRect.color=color; draw(); } }; });
805
-
806
- // Timeline (+fenêtrage)
807
- async function loadTimelineUrls(){ timelineUrls=[]; const stem=vidStem,b=bustToken; for(let idx=0; idx<frames; idx++){ timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`; } tlProgressFill.style.width='0%'; }
808
- let timelineStart = 0, timelineEnd = 0;
809
- async function renderTimeline(centerIdx){ if(!vidName) return; loadingInd.style.display='block'; if(timelineUrls.length===0) await loadTimelineUrls(); tlBox.innerHTML=''; thumbEls=new Map(); if(maskedOnlyMode){ const idxs = Array.from(maskedSet).sort((a,b)=>a-b); for(const i of idxs){ addThumb(i,'append'); } setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); },0); return; }
810
- await loadWindow(centerIdx ?? currentIdx); loadingInd.style.display='none'; }
811
- async function loadWindow(centerIdx){ tlBox.innerHTML=''; thumbEls=new Map(); const rngStart=(viewRangeStart ?? 0), rngEnd=(viewRangeEnd ?? frames); const mid=Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1))); const start=Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize))); const end=Math.min(rngEnd, start + chunkSize); for(let i=start;i<end;i++){ addThumb(i,'append'); } timelineStart=start; timelineEnd=end; setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); },0); }
812
- function addThumb(idx, place='append'){ 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]; img.onerror=()=>{ const fallback=`/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`; img.onerror=null; img.src=fallback; img.onload=()=>{ const nu=`/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`; timelineUrls[idx]=nu; img.src=nu; img.onload=null; }; }; if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); } img.onclick=async ()=>{ currentIdx=idx; player.currentTime=idxToSec(currentIdx); if(mode==='edit'){ draw(); } updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead(); }; wrap.appendChild(img); const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`; wrap.appendChild(label); if(place==='append') tlBox.appendChild(wrap); else if(place==='prepend') tlBox.insertBefore(wrap, tlBox.firstChild); else tlBox.appendChild(wrap); thumbEls.set(idx, wrap); }
813
-
814
- tlBox.addEventListener('scroll', ()=>{ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){ updatePlayhead(); return; } const scrollLeft=tlBox.scrollLeft, scrollWidth=tlBox.scrollWidth, clientWidth=tlBox.clientWidth; if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){ const newEnd=Math.min(viewRangeEnd, timelineEnd + chunkSize); for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); } timelineEnd=newEnd; } if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){ const newStart=Math.max(viewRangeStart, timelineStart - chunkSize); for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); } tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8); timelineStart=newStart; } updatePlayhead(); });
815
-
816
- // Portion
817
- function portionBand(){ let el=document.getElementById('portionBand'); if(!el){ el=document.createElement('div'); el.id='portionBand'; el.style.position='absolute'; el.style.top='0'; el.style.height='calc(var(--thumbH,110px) + 24px)'; el.style.background='rgba(37,99,235,.12)'; el.style.pointerEvents='none'; el.style.display='none'; tlBox.appendChild(el);} return el; }
818
- function handle(id){ let el=document.getElementById(id); if(!el){ el=document.createElement('div'); el.id=id; el.style.position='absolute'; el.style.top='0'; el.style.height='calc(var(--thumbH,110px) + 24px)'; el.style.width='6px'; el.style.background='rgba(37,99,235,.9)'; el.style.borderRadius='2px'; el.style.cursor='ew-resize'; el.style.display='none'; el.style.pointerEvents='auto'; tlBox.appendChild(el);} return el; }
819
- const inH=handle('inHandle'), outH=handle('outHandle');
820
- function updatePortionOverlays(){ const pb=portionBand(); if(portionStart==null || portionEnd==null){ pb.style.display='none'; inH.style.display='none'; outH.style.display='none'; return; } const a=findThumbEl(portionStart), b=findThumbEl(portionEnd-1); if(!a || !b){ pb.style.display='none'; inH.style.display='none'; outH.style.display='none'; return; } const left=a.offsetLeft, right=b.offsetLeft + b.clientWidth; pb.style.display='block'; pb.style.left=left+'px'; pb.style.width=Math.max(0,right-left)+'px'; inH.style.display='block'; inH.style.left=(left - inH.clientWidth/2)+'px'; outH.style.display='block'; outH.style.left=(right - outH.clientWidth/2)+'px'; }
821
- function nearestFrameIdxFromClientX(clientX){ const rect=tlBox.getBoundingClientRect(); const xIn=clientX - rect.left + tlBox.scrollLeft; let bestIdx=currentIdx, bestDist=Infinity; for(const [idx, el] of thumbEls.entries()){ const mid=el.offsetLeft + el.clientWidth/2; const d=Math.abs(mid - xIn); if(d<bestDist){ bestDist=d; bestIdx=idx; } } return bestIdx; }
822
- function attachHandleDrag(handle, which){ let draggingH=false; function onMove(e){ if(!draggingH) return; const idx=nearestFrameIdxFromClientX(e.clientX); if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); } else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; } viewRangeStart=(portionStart ?? 0); viewRangeEnd=(portionEnd ?? frames); updatePortionOverlays(); }
823
- handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', ()=>{ draggingH=false; }); }
824
- attachHandleDrag(inH,'in'); attachHandleDrag(outH,'out');
825
-
826
- isolerBoucle.onclick = async ()=>{ const start=parseInt(goFrame.value||'1',10)-1; const end=parseInt(endPortion.value||'',10); if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; } if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return; portionStart=start; portionEnd=end; viewRangeStart=start; viewRangeEnd=end; player.pause(); isPaused=true; currentIdx=start; player.currentTime=idxToSec(start); await renderTimeline(currentIdx); resetFull.style.display='inline-block'; startLoop(); updatePortionOverlays(); };
827
- function startLoop(){ if(loopInterval) clearInterval(loopInterval); if(portionEnd != null){ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100); } }
828
- resetFull.onclick = async ()=>{ portionStart=null; portionEnd=null; viewRangeStart=0; viewRangeEnd=frames; goFrame.value=1; endPortion.value=''; player.pause(); isPaused=true; await renderTimeline(currentIdx); resetFull.style.display='none'; clearInterval(loopInterval); updatePortionOverlays(); };
829
-
830
- // Play/time
831
- player.addEventListener('loadedmetadata', async ()=>{ fitCanvas(); if(!frames || frames<=0){ 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{} } currentIdx=0; goFrame.value=1; draw(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  window.addEventListener('resize', ()=>{ fitCanvas(); });
833
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
834
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
835
- player.addEventListener('timeupdate', ()=>{ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s'; currentIdx=timeToIdx(player.currentTime); if(mode==='edit'){ draw(); } updateHUD(); updateSelectedThumb(); updatePlayhead(); if(followMode && !isPaused){ const now=Date.now(); if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs=now; centerSelectedThumb(); } } });
836
-
837
- goFrame.addEventListener('change', async ()=>{ if(!vidName) return; const val=Math.max(1, parseInt(goFrame.value||'1',10)); player.pause(); isPaused=true; currentIdx=val-1; player.currentTime=idxToSec(currentIdx); if(mode==='edit'){ draw(); } await renderTimeline(currentIdx); await ensureThumbVisibleCentered(currentIdx); updatePortionOverlays(); });
838
-
839
- // Follow / Filter / Zoom / Goto
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
841
- btnFilterMasked.onclick = async ()=>{ maskedOnlyMode = !maskedOnlyMode; btnFilterMasked.classList.toggle('toggled', maskedOnlyMode); tlBox.classList.toggle('filter-masked', maskedOnlyMode); await renderTimeline(currentIdx); await ensureThumbVisibleCentered(currentIdx); };
 
 
 
 
 
 
842
  zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
843
- async function gotoFrameNum(){ const v = parseInt(gotoInput.value||'',10); if(!Number.isFinite(v) || v<1 || v>frames) return; player.pause(); isPaused = true; currentIdx = v-1; player.currentTime = idxToSec(currentIdx); goFrame.value = v; await renderTimeline(currentIdx); await ensureThumbVisibleCentered(currentIdx); }
 
 
 
 
 
 
 
 
844
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
845
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
846
-
847
- // Upload drag & drop
848
  const uploadZone = document.getElementById('uploadForm');
849
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
850
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
851
- uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.style.borderColor = 'transparent'; const file = e.dataTransfer.files[0]; if(file && file.type.startsWith('video/')){ const fd = new FormData(); fd.append('file', file); fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload()); } });
852
-
853
- // Export placeholder
854
- document.getElementById('exportBtn').onclick = () => { alert('Preview/Export IA en développement. Active GPU et reviens prochainement.'); };
855
-
856
- // Fichiers & masques
857
- async function loadFiles(){ const r=await fetch('/files'); const d=await r.json(); if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; } fileList.innerHTML=''; d.items.forEach(name=>{ const li=document.createElement('li'); const delBtn=document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo'; delBtn.onclick=async()=>{ if(!confirm(`Supprimer "${name}" ?`)) return; await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'}); loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); } }; const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo'; li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li); }); }
858
-
859
- async function loadMasks(){ loadingInd.style.display='block'; const box=document.getElementById('maskList'); const r=await fetch('/mask/'+encodeURIComponent(vidName)); const d=await r.json(); masks=d.masks||[]; maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10))); // actualise étoiles
860
- maskedCount.textContent = `(${maskedSet.size} ⭐)`; if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
861
- box.innerHTML=''; const ul=document.createElement('ul'); ul.className='clean'; masks.forEach(m=>{ 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`;
862
- const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque'; renameBtn.onclick=async()=>{ const nv=prompt('Nouveau nom du masque :', label); if(nv===null) return; const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})}); if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); showToast('Masque renommé ✅'); } else { const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt); } };
863
- const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque'; delMaskBtn.onclick=async()=>{ if(!confirm(`Supprimer masque "${label}" ?`)) return; const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})}); if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); showToast('Masque supprimé ✅'); } else { const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt); } };
864
- li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li); }); box.appendChild(ul); loadingInd.style.display='none'; }
865
-
866
- // Save masques (tous les rectangles non sauvegardés de la frame)
867
- btnSave.onclick = async ()=>{ const L = allUnsavedFor(currentIdx).slice(); if(liveRect) L.push(liveRect); if(!L.length){ alert('Aucune sélection.'); return; } const base = prompt('Nom de base pour ces masques (optionnel) :','frame '+(currentIdx+1)) || ('frame '+(currentIdx+1)); let seq=1; for(const r of L){ const payload={ vid:vidName, time_s:player.currentTime, frame_idx:currentIdx, shape:'rect', points: normRect(r), color: r.color||color, note: (L.length>1 ? `${base} (${seq++})` : base) }; try{ const rr = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); if(!rr.ok){ const txt=await rr.text(); alert('Échec enregistrement masque: '+rr.status+' '+txt); break; } }catch(e){ alert('Erreur réseau lors de l’enregistrement.'); break; }
868
- }
869
- // Clear stack après save
870
- const s=ensureStack(currentIdx); s.list.length=0; s.undo.length=0; s.redo.length=0; liveRect=null; await loadMasks(); await renderTimeline(currentIdx); showToast('Masques enregistrés ✅'); };
871
-
872
- // Warm‑up Hub
873
- let wuTimer=null; async function pollWarmup(){ try{ const r=await fetch('/warmup/status'); const d=await r.json(); wuLabel.textContent = d.running ? (d.current || '...') : (d.done ? 'Terminé' : '—'); wuFill.style.width = (d.percent||0)+'%'; if(d.logs && d.logs.length){ // no log box ici, compacte
874
- } if(!d.running && (!d.done || d.percent<100)){ /* keep idle */ } }catch{} }
875
- btnWarmup.onclick = async ()=>{ try{ const r=await fetch('/warmup/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); const d=await r.json(); if(d.ok){ showToast('Warm‑up lancé'); if(wuTimer) clearInterval(wuTimer); wuTimer=setInterval(pollWarmup, 1000); } else { showToast('Warm‑up déjà en cours'); } }catch{ showToast('Warm‑up: erreur réseau'); } };
876
-
877
- // Progress popup thumbs
878
- async function showProgress(vidStem){ popup.style.display='block'; const interval=setInterval(async()=>{ const r=await fetch('/progress/'+vidStem); const d=await r.json(); tlProgressFill.style.width=d.percent+'%'; popupProgressFill.style.width=d.percent+'%'; popupLogs.innerHTML=(d.logs||[]).map(x=>String(x)).join('<br>'); if(d.done){ clearInterval(interval); popup.style.display='none'; await renderTimeline(currentIdx); } }, 800); }
879
-
880
- // Boot
881
- async function loadVideoAndMeta(){ 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)`; viewRangeStart=0; viewRangeEnd=frames; await loadTimelineUrls(); await loadMasks(); currentIdx=0; player.currentTime=0; await renderTimeline(0); showProgress(vidStem); } else { statusEl.textContent = 'Erreur meta'; } }catch(err){ statusEl.textContent = 'Erreur réseau meta'; } }
882
-
883
- async function boot(){ await loadFiles(); if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); } else { const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
  boot();
885
-
886
- // Cacher contrôles en mode edit (WebKit)
887
  const style = document.createElement('style');
888
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
889
  document.head.appendChild(style);
890
  </script>
891
  </html>
892
  """
893
-
894
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
895
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
896
- vid = v or ""
897
- try:
898
- msg = urllib.parse.unquote(msg or "")
899
- except Exception:
900
- pass
901
- html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
902
- return HTMLResponse(content=html)
 
1
  # app.py — Video Editor API (v0.8.1)
2
+ # v0.8.1: Fix bugs (timeline/frames/progress/import), prefix pour pointer, + Améliorations intégrées
 
 
 
 
 
 
 
 
 
 
3
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
4
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
5
  from fastapi.staticfiles import StaticFiles
 
11
  import shutil as _shutil
12
  import os
13
  import httpx
14
+ import huggingface_hub as hf
15
+ from joblib import Parallel, delayed
16
+ # --- POINTEUR (inchangé) ---
17
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
18
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
19
  _backend_url_cache = {"url": None, "ts": 0.0}
 
20
  def get_backend_base() -> str:
21
  try:
22
  if POINTER_URL:
23
  now = time.time()
24
+ need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
25
  if need_refresh:
26
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
27
  url = (r.text or "").strip()
 
34
  return FALLBACK_BASE
35
  except Exception:
36
  return FALLBACK_BASE
 
 
37
  print("[BOOT] Video Editor API starting…")
38
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
39
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
 
40
  app = FastAPI(title="Video Editor API", version="0.8.1")
 
41
  DATA_DIR = Path("/app/data")
42
  THUMB_DIR = DATA_DIR / "_thumbs"
43
  MASK_DIR = DATA_DIR / "_masks"
44
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
45
  p.mkdir(parents=True, exist_ok=True)
 
46
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
47
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
48
+ # --- PROXY (inchangé) ---
49
+ @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
50
+ async def proxy_all(full_path: str, request: Request):
51
+ base = get_backend_base().rstrip("/")
52
+ target = f"{base}/{full_path}"
53
+ qs = request.url.query
54
+ if qs:
55
+ target = f"{target}?{qs}"
56
+ body = await request.body()
57
+ headers = dict(request.headers)
58
+ headers.pop("host", None)
59
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
60
+ r = await client.request(request.method, target, headers=headers, content=body)
61
+ drop = {"content-encoding","transfer-encoding","connection",
62
+ "keep-alive","proxy-authenticate","proxy-authorization",
63
+ "te","trailers","upgrade"}
64
+ out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
65
+ return Response(content=r.content, status_code=r.status_code, headers=out_headers)
66
+ # --- Helpers (inchangé) ---
67
  def _is_video(p: Path) -> bool:
68
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
 
69
  def _safe_name(name: str) -> str:
70
  return Path(name).name.replace(" ", "_")
 
71
  def _has_ffmpeg() -> bool:
72
  return _shutil.which("ffmpeg") is not None
 
73
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
74
  return f"scale=min(iw\\,{max_w}):-2"
 
75
  def _meta(video: Path):
76
  cap = cv2.VideoCapture(str(video))
77
  if not cap.isOpened():
 
84
  cap.release()
85
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
86
  return {"frames": frames, "fps": fps, "w": w, "h": h}
 
87
  def _frame_jpg(video: Path, idx: int) -> Path:
88
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
89
  if out.exists():
 
129
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
130
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
131
  return out
 
132
  def _poster(video: Path) -> Path:
133
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
134
  if out.exists():
 
143
  except Exception as e:
144
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
145
  return out
 
146
  def _mask_file(vid: str) -> Path:
147
  return MASK_DIR / f"{Path(vid).name}.json"
 
148
  def _load_masks(vid: str) -> Dict[str, Any]:
149
  f = _mask_file(vid)
150
  if f.exists():
 
153
  except Exception as e:
154
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
155
  return {"video": vid, "masks": []}
 
156
  def _save_masks(vid: str, data: Dict[str, Any]):
157
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
158
  def _gen_thumbs_background(video: Path, vid_stem: str):
159
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
160
  try:
 
168
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
169
  progress_data[vid_stem]['done'] = True
170
  return
 
171
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
172
  f.unlink(missing_ok=True)
173
  if _has_ffmpeg():
 
231
  except Exception as e:
232
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
233
  progress_data[vid_stem]['done'] = True
234
+ def is_gpu():
235
+ return False
236
+ # --- Chargement Modèles (inchangé) ---
237
+ models = [
238
+ "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
239
+ "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
240
+ ]
241
+ PROP_URLS = [
242
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth",
243
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth",
244
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth"
245
+ ]
246
+ def load_model(repo_id, retry=3):
247
+ path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
248
+ for attempt in range(retry):
249
+ try:
250
+ if not path.exists() or not any(path.iterdir()):
251
+ warmup_progress['logs'].append(f"Downloading {repo_id} (attempt {attempt+1})...")
252
+ hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
253
+ (path / "loaded.ok").touch()
254
+ if "sam2" in repo_id:
255
+ shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
256
+ warmup_progress['logs'].append(f"{repo_id} ready.")
257
+ return True
258
+ except Exception as e:
259
+ warmup_progress['logs'].append(f"Error {repo_id}: {e} (retrying...)")
260
+ return False
261
+ def wget_prop(url, dest, retry=3):
262
+ fname = url.split("/")[-1]
263
+ out = dest / fname
264
+ for attempt in range(retry):
265
+ try:
266
+ if not out.exists():
267
+ warmup_progress['logs'].append(f"Downloading {fname} (attempt {attempt+1})...")
268
+ subprocess.run(["wget", "-q", url, "-P", str(dest)], check=True)
269
+ warmup_progress['logs'].append(f"{fname} ready.")
270
+ return True
271
+ except Exception as e:
272
+ warmup_progress['logs'].append(f"Error {fname}: {e} (retrying...)")
273
+ return False
274
+ @app.post("/warmup")
275
+ def warmup():
276
+ if warmup_progress['in_progress']:
277
+ raise HTTPException(400, "Warm-up already in progress.")
278
+ warmup_progress['in_progress'] = True
279
+ warmup_progress['percent'] = 0
280
+ warmup_progress['logs'] = ["Warm-up started."]
281
+ threading.Thread(target=_run_warmup, daemon=True).start()
282
+ return {"ok": True}
283
+ def _run_warmup():
284
+ total = len(models) + len(PROP_URLS)
285
+ done = 0
286
+ for m in models:
287
+ if load_model(m):
288
+ done += 1
289
+ warmup_progress['percent'] = int((done / total) * 100)
290
+ else:
291
+ warmup_progress['logs'].append(f"Failed {m} after retries.")
292
+ PROP = Path("/app/propainter")
293
+ PROP.mkdir(exist_ok=True)
294
+ for url in PROP_URLS:
295
+ if wget_prop(url, PROP):
296
+ done += 1
297
+ warmup_progress['percent'] = int((done / total) * 100)
298
+ else:
299
+ warmup_progress['logs'].append(f"Failed {url.split('/')[-1]} after retries.")
300
+ warmup_progress['done'] = True
301
+ warmup_progress['in_progress'] = False
302
+ warmup_progress['logs'].append("Warm-up complete.")
303
+ @app.get("/warmup_progress")
304
+ def get_warmup_progress():
305
+ return warmup_progress
306
+ # --- IA stubs (inchangé) ---
307
+ @app.post("/mask/ai")
308
+ async def mask_ai(payload: Dict[str, Any] = Body(...)):
309
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
310
+ return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
311
+ @app.post("/inpaint")
312
+ async def inpaint(payload: Dict[str, Any] = Body(...)):
313
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
314
+ return {"ok": True, "preview": "/data/preview.mp4"}
315
+ @app.get("/estimate")
316
+ def estimate(vid: str, masks_count: int):
317
+ return {"time_min": 5, "vram_gb": 4} if is_gpu() else {"time_min": "N/A (CPU)", "vram_gb": "N/A (CPU)"}
318
+ @app.get("/progress_ia")
319
+ def progress_ia(vid: str):
320
+ return {"percent": 0, "log": "En attente..."}
321
+ # --- Masques (inchangé) ---
322
+ @app.post("/mask", tags=["mask"])
323
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
324
+ vid = payload.get("vid")
325
+ if not vid:
326
+ raise HTTPException(400, "vid manquant")
327
+ pts = payload.get("points") or []
328
+ if len(pts) != 4:
329
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
330
+ data = _load_masks(vid)
331
+ m = {
332
+ "id": uuid.uuid4().hex[:10],
333
+ "time_s": float(payload.get("time_s") or 0.0),
334
+ "frame_idx": int(payload.get("frame_idx") or 0),
335
+ "shape": "rect",
336
+ "points": [float(x) for x in pts],
337
+ "color": payload.get("color") or "#10b981",
338
+ "note": payload.get("note") or ""
339
+ }
340
+ data.setdefault("masks", []).append(m)
341
+ _save_masks(vid, data)
342
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
343
+ return {"saved": True, "mask": m}
344
+ @app.get("/mask/{vid}", tags=["mask"])
345
+ def list_masks(vid: str, frame_idx: Optional[int] = None):
346
+ data = _load_masks(vid)
347
+ if frame_idx is not None:
348
+ return [m for m in data["masks"] if m["frame_idx"] == frame_idx]
349
+ return data
350
+ @app.post("/mask/rename", tags=["mask"])
351
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
352
+ vid = payload.get("vid")
353
+ mid = payload.get("id")
354
+ new_note = (payload.get("note") or "").strip()
355
+ if not vid or not mid:
356
+ raise HTTPException(400, "vid et id requis")
357
+ data = _load_masks(vid)
358
+ for m in data.get("masks", []):
359
+ if m.get("id") == mid:
360
+ m["note"] = new_note
361
+ _save_masks(vid, data)
362
+ return {"ok": True}
363
+ raise HTTPException(404, "Masque introuvable")
364
+ @app.post("/mask/delete", tags=["mask"])
365
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
366
+ vid = payload.get("vid")
367
+ mid = payload.get("id")
368
+ if not vid or not mid:
369
+ raise HTTPException(400, "vid et id requis")
370
+ data = _load_masks(vid)
371
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
372
+ _save_masks(vid, data)
373
+ return {"ok": True}
374
+ # --- API autres (inchangé) ---
375
  @app.get("/", tags=["meta"])
376
  def root():
377
+ return {"ok": True, "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup", "/warmup_progress", "/mask/ai", "/inpaint", "/estimate", "/progress_ia"]}
 
 
 
 
378
  @app.get("/health", tags=["meta"])
379
  def health():
380
  return {"status": "ok"}
 
381
  @app.get("/_env", tags=["meta"])
382
  def env_info():
383
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  @app.get("/files", tags=["io"])
385
  def files():
386
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
387
  return {"count": len(items), "items": items}
 
388
  @app.get("/meta/{vid}", tags=["io"])
389
  def video_meta(vid: str):
390
  v = DATA_DIR / vid
 
394
  if not m:
395
  raise HTTPException(500, "Métadonnées indisponibles")
396
  return m
 
397
  @app.post("/upload", tags=["io"])
398
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
399
  ext = (Path(file.filename).suffix or ".mp4").lower()
 
414
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
415
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
416
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
 
417
  @app.get("/progress/{vid_stem}", tags=["io"])
418
  def progress(vid_stem: str):
419
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
 
420
  @app.delete("/delete/{vid}", tags=["io"])
421
  def delete_video(vid: str):
422
  v = DATA_DIR / vid
 
429
  v.unlink(missing_ok=True)
430
  print(f"[DELETE] {vid}", file=sys.stdout)
431
  return {"deleted": vid}
 
432
  @app.get("/frame_idx", tags=["io"])
433
  def frame_idx(vid: str, idx: int):
434
  v = DATA_DIR / vid
 
444
  except Exception as e:
445
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
446
  raise HTTPException(500, "Frame error")
 
447
  @app.get("/poster/{vid}", tags=["io"])
448
  def poster(vid: str):
449
  v = DATA_DIR / vid
 
453
  if p.exists():
454
  return FileResponse(str(p), media_type="image/jpeg")
455
  raise HTTPException(404, "Poster introuvable")
 
456
  @app.get("/window/{vid}", tags=["io"])
457
  def window(vid: str, center: int = 0, count: int = 21):
458
  v = DATA_DIR / vid
 
480
  items.append({"i": i, "idx": idx, "url": url})
481
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
482
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
483
+ # --- UI (ajouts prefix pour pointer, bouton estimer) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  HTML_TEMPLATE = r"""
485
  <!doctype html>
486
  <html lang="fr"><meta charset="utf-8">
487
  <title>Video Editor</title>
488
+ <style>
489
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
490
+ * {box-sizing:border-box}
491
+ body {font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
492
+ h1 {margin:0 0 8px 0}
493
+ .topbar {display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
494
+ .card {border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
495
+ .muted {color:var(--muted);font-size:13px}
496
+ .layout {display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
497
+ .viewer {max-width:1024px;margin:0 auto; position:relative}
498
+ .player-wrap {position:relative; padding-bottom: var(--controlsH);}
499
+ video {display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
500
+ #editCanvas {position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
501
+ .timeline-container {margin-top:10px}
502
+ .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%}
503
+ .thumb {flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
504
+ .thumb:hover {transform:scale(1.05)}
505
+ .thumb img {height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
506
+ .thumb img.sel {border-color:var(--active-border)}
507
+ .thumb img.sel-strong {outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
508
+ .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}
509
+ .thumb-label {font-size:11px;color:var(--muted);margin-top:2px;display:block}
510
+ .timeline.filter-masked .thumb:not(.hasmask) {display:none}
511
+ .tools .row {display:flex;gap:8px;flex-wrap:wrap;align-items:center}
512
+ .btn {padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
513
+ .btn:hover {background:var(--active-bg);border-color:var(--active-border)}
514
+ .btn.active,.btn.toggled {background:var(--active-bg);border-color:var(--active-border)}
515
+ .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}
516
+ .swatch.sel {box-shadow:0 0 0 2px var(--active-border)}
517
+ ul.clean {list-style:none;padding-left:0;margin:6px 0}
518
+ ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
519
+ .rename-btn {font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
520
+ .rename-btn:hover {color:#2563eb}
521
+ .delete-btn {color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
522
+ .delete-btn:hover {color:#b91c1c}
523
+ #loading-indicator {display:none;margin-top:6px;color:#f59e0b}
524
+ .portion-row {display:flex;gap:6px;align-items:center;margin-top:8px}
525
+ .portion-input {width:70px}
526
+ #tl-progress-bar {background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
527
+ #tl-progress-fill {background:#2563eb;height:100%;width:0;border-radius:4px}
528
+ #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}
529
+ #popup-progress-bar {background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
530
+ #popup-progress-fill {background:#2563eb;height:100%;width:0;border-radius:4px}
531
+ #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
532
+ .playhead {position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
533
+ #portionBand {position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
534
+ #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}
535
+ #hud {position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
536
+ #toast {position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
537
+ .toast-item {background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
538
+ #tutorial {background:#fef9c3;padding:10px;border-radius:8px;margin-top:12px;display:block}
539
+ #tutorial.hidden {display:none}
540
+ #warmupBtn {margin-top:8px}
541
+ #iaPreviewBtn {margin-top:8px}
542
+ #iaProgress {margin-top:8px; background:#f3f4f6;border-radius:4px;height:8px}
543
+ #iaProgressFill {background:#2563eb;height:100%;width:0;border-radius:4px}
544
+ #iaLogs {font-size:12px;color:#6b7280;margin-top:4px}
545
+ #multiMaskList {max-height:150px;overflow:auto}
546
+ #estimateBtn {margin-top:8px}
547
  </style>
548
  <h1>🎬 Video Editor</h1>
549
  <div class="topbar card">
550
+ <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
551
+ <strong>Charger une vidéo :</strong>
552
+ <input type="file" name="file" accept="video/*" required>
553
+ <button class="btn" type="submit">Uploader</button>
554
+ </form>
555
+ <span class="muted" id="msg">__MSG__</span>
556
+ <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
 
 
557
  </div>
558
  <div class="layout">
559
  <div>
 
595
  <div class="row" style="margin-top:6px">
596
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
597
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
598
+ <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
599
+ <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
600
+ <button id="undoBtn" class="btn" style="display:none">↩️ Undo</button>
601
+ <button id="redoBtn" class="btn" style="display:none">↪️ Redo</button>
602
  </div>
603
  <div style="margin-top:10px">
604
  <div class="muted">Couleur</div>
 
612
  </div>
613
  <div style="margin-top:12px">
614
  <details open>
615
+ <summary><strong>Masques</strong></summary>
616
  <div id="maskList" class="muted">—</div>
617
+ <div id="multiMaskList" class="muted">—</div>
618
+ <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>
619
+ <button class="btn" id="iaPreviewBtn" title="Preview IA (en développement)">🔍 Preview IA</button>
620
+ <button class="btn" id="estimateBtn" title="Estimer temps/VRAM">⏱️ Estimer</button>
621
  </details>
622
+ <div id="iaProgress"><div id="iaProgressFill"></div></div>
623
+ <div id="iaLogs"></div>
624
  <div style="margin-top:6px">
625
  <strong>Vidéos disponibles</strong>
626
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
627
  </div>
628
  </div>
629
+ <button class="btn" id="warmupBtn">Warm-up Modèles</button>
 
 
 
630
  </div>
631
  </div>
632
  <div id="popup">
 
635
  <div id="popup-logs"></div>
636
  </div>
637
  <div id="toast"></div>
638
+ <div id="tutorial">
639
+ <h4>Tutoriel</h4>
640
+ <p>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</p>
641
+ <button onclick="this.parentElement.classList.add('hidden')">Masquer</button>
642
+ </div>
643
  <script>
644
  const serverVid = "__VID__";
645
  const serverMsg = "__MSG__";
646
  document.getElementById('msg').textContent = serverMsg;
647
+ // Ajout prefix pour pointer/backend
648
+ let prefix = '';
649
+ // Elements (inchangé)
650
  const statusEl = document.getElementById('status');
651
  const player = document.getElementById('player');
652
  const srcEl = document.getElementById('vidsrc');
 
657
  const btnBack = document.getElementById('btnBack');
658
  const btnSave = document.getElementById('btnSave');
659
  const btnClear= document.getElementById('btnClear');
 
 
660
  const posInfo = document.getElementById('posInfo');
661
  const goFrame = document.getElementById('goFrame');
662
  const palette = document.getElementById('palette');
 
680
  const toastWrap = document.getElementById('toast');
681
  const gotoInput = document.getElementById('gotoInput');
682
  const gotoBtn = document.getElementById('gotoBtn');
683
+ const tutorial = document.getElementById('tutorial');
684
+ const warmupBtn = document.getElementById('warmupBtn');
685
+ const iaPreviewBtn = document.getElementById('iaPreviewBtn');
686
+ const iaProgressFill = document.getElementById('iaProgressFill');
687
+ const iaLogs = document.getElementById('iaLogs');
688
+ const undoBtn = document.getElementById('undoBtn');
689
+ const redoBtn = document.getElementById('redoBtn');
690
+ const multiMaskList = document.getElementById('multiMaskList');
691
+ const estimateBtn = document.getElementById('estimateBtn');
692
+ // State (inchangé)
693
  let vidName = serverVid || '';
694
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
695
  let vidStem = '';
 
697
  let fps = 30, frames = 0;
698
  let currentIdx = 0;
699
  let mode = 'view';
700
+ let rects = [];
701
+ let dragging=false, sx=0, sy=0, currentRect = null;
702
  let color = '#10b981';
703
+ let rectMap = new Map();
704
+ let history = [];
705
+ let historyIdx = -1;
706
  let masks = [];
707
  let maskedSet = new Set();
708
  let timelineUrls = [];
 
710
  let portionEnd = null;
711
  let loopInterval = null;
712
  let chunkSize = 50;
713
+ let timelineStart = 0, timelineEnd = 0;
714
  let viewRangeStart = 0, viewRangeEnd = 0;
715
  const scrollThreshold = 100;
716
  let followMode = false;
 
718
  let thumbEls = new Map();
719
  let lastCenterMs = 0;
720
  const CENTER_THROTTLE_MS = 150;
721
+ const PENDING_KEY = 've_pending_masks_v1';
722
  let maskedOnlyMode = false;
723
+ // Utils (inchangé)
 
 
 
 
 
 
 
 
724
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
725
+ function ensureOverlays(){
726
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
727
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
728
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
729
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
730
+ }
731
+ function playheadEl(){ return document.getElementById('playhead'); }
732
+ function portionBand(){ return document.getElementById('portionBand'); }
733
+ function inHandle(){ return document.getElementById('inHandle'); }
734
+ function outHandle(){ return document.getElementById('outHandle'); }
735
  function findThumbEl(idx){ return thumbEls.get(idx) || null; }
736
+ function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s #${cur}/${total} • ${fps.toFixed(2)}fps`; }
737
+ function updateSelectedThumb(){
738
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
739
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
740
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
741
+ }
742
+ function rawCenterThumb(el){
743
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
744
+ }
745
+ async function ensureThumbVisibleCentered(idx){
746
+ for(let k=0; k<40; k++){
747
+ const el = findThumbEl(idx);
748
+ if(el){
749
+ const img = el.querySelector('img');
750
+ if(!img.complete || img.naturalWidth === 0){
751
+ await new Promise(r=>setTimeout(r, 25));
752
+ }else{
753
+ rawCenterThumb(el);
754
+ updatePlayhead();
755
+ return true;
756
+ }
757
+ }else{
758
+ await new Promise(r=>setTimeout(r, 25));
759
+ }
760
+ }
761
+ return false;
762
+ }
763
  function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
764
+ function updatePlayhead(){
765
+ ensureOverlays();
766
+ const el = findThumbEl(currentIdx);
767
+ const ph = playheadEl();
768
+ if(!el){ ph.style.display='none'; return; }
769
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
770
+ }
771
+ function updatePortionOverlays(){
772
+ ensureOverlays();
773
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
774
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
775
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
776
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
777
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
778
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
779
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
780
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
781
+ }
782
+ function nearestFrameIdxFromClientX(clientX){
783
+ const rect = tlBox.getBoundingClientRect();
784
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
785
+ let bestIdx = currentIdx, bestDist = Infinity;
786
+ for(const [idx, el] of thumbEls.entries()){
787
+ const mid = el.offsetLeft + el.clientWidth/2;
788
+ const d = Math.abs(mid - xIn);
789
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
790
+ }
791
+ return bestIdx;
792
+ }
793
+ function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
794
+ function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
795
+ function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
796
+ async function flushPending(){
797
+ const lst = loadPending(); if(!lst.length) return;
798
+ const kept = [];
799
+ for(const p of lst){
800
+ try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
801
+ catch{ kept.push(p); }
802
+ }
803
+ savePendingList(kept);
804
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
805
+ }
806
+ const AUTOSAVE_KEY = 've_autosave_rects_v1';
807
+ function saveAutoRects(){ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({vid:vidName, idx:currentIdx, rects:rects})); }
808
+ function loadAutoRects(){
809
+ try{
810
+ const d = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) || '{}');
811
+ if(d.vid === vidName && d.idx === currentIdx){ rects = d.rects || []; draw(); }
812
+ }catch{}
813
+ }
814
+ // Layout (inchangé)
815
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
816
+ function fitCanvas(){
817
+ const r=player.getBoundingClientRect();
818
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
819
+ canvas.width=Math.round(r.width);
820
+ canvas.height=Math.round(r.height - ctrlH);
821
+ canvas.style.width=r.width+'px';
822
+ canvas.style.height=(r.height - ctrlH)+'px';
823
+ syncTimelineWidth();
824
+ }
825
  function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
826
  function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
827
+ function setMode(m){
828
+ mode=m;
829
+ if(m==='edit'){
830
+ player.pause();
831
+ player.controls = false;
832
+ playerWrap.classList.add('edit-mode');
833
+ modeLabel.textContent='Édition';
834
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
835
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
836
+ undoBtn.style.display='inline-block'; redoBtn.style.display='inline-block';
837
+ canvas.style.pointerEvents='auto';
838
+ rects = rectMap.get(currentIdx) || [];
839
+ history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); loadAutoRects(); updateUndoRedo();
840
+ }else{
841
+ player.controls = true;
842
+ playerWrap.classList.remove('edit-mode');
843
+ modeLabel.textContent='Lecture';
844
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
845
+ btnSave.style.display='none'; btnClear.style.display='none';
846
+ undoBtn.style.display='none'; redoBtn.style.display='none';
847
+ canvas.style.pointerEvents='none';
848
+ rects=[]; draw();
849
+ }
850
+ }
851
+ function draw(){
852
+ ctx.clearRect(0,0,canvas.width,canvas.height);
853
+ rects.forEach(r => {
854
+ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2);
855
+ const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1);
856
+ ctx.strokeStyle=r.color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
857
+ ctx.fillStyle=r.color+'28'; ctx.fillRect(x,y,w,h);
858
+ });
859
+ }
860
+ canvas.addEventListener('mousedown',(e)=>{
861
+ if(mode!=='edit' || !vidName) return;
862
+ dragging=true; const r=canvas.getBoundingClientRect();
863
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
864
+ currentRect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; rects.push(currentRect); draw(); saveAutoRects();
865
+ });
866
+ canvas.addEventListener('mousemove',(e)=>{
867
+ if(!dragging) return;
868
+ const r=canvas.getBoundingClientRect();
869
+ currentRect.x2=e.clientX-r.left; currentRect.y2=e.clientY-r.top; draw();
870
+ });
871
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{
872
+ dragging=false;
873
+ if(currentRect){ pushHistory(); currentRect = null; }
874
+ }));
875
+ function pushHistory(){
876
+ history = history.slice(0, historyIdx + 1);
877
+ history.push(JSON.stringify(rects));
878
+ historyIdx++;
879
+ updateUndoRedo();
880
+ saveAutoRects();
881
+ }
882
+ function updateUndoRedo(){
883
+ undoBtn.disabled = historyIdx <= 0;
884
+ redoBtn.disabled = historyIdx >= history.length - 1;
885
+ }
886
+ undoBtn.onclick = () => {
887
+ if(historyIdx > 0){ historyIdx--; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
888
+ };
889
+ redoBtn.onclick = () => {
890
+ if(historyIdx < history.length - 1){ historyIdx++; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
891
+ };
892
+ btnClear.onclick=()=>{ if(rects.length) rects.pop(); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
893
  btnEdit.onclick =()=> setMode('edit');
894
  btnBack.onclick =()=> setMode('view');
895
+ palette.querySelectorAll('.swatch').forEach(el=>{
896
+ if(el.dataset.c===color) el.classList.add('sel');
897
+ el.onclick=()=>{
898
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
899
+ el.classList.add('sel'); color=el.dataset.c;
900
+ if(currentRect){ currentRect.color=color; draw(); }
901
+ };
902
+ });
903
+ // Timeline (préfixes ajoutés)
904
+ async function loadTimelineUrls(){
905
+ timelineUrls = [];
906
+ const stem = vidStem, b = bustToken;
907
+ for(let idx=0; idx<frames; idx++){
908
+ timelineUrls[idx] = prefix + `thumbs/f_${stem}_${idx}.jpg?b=${b}`;
909
+ }
910
+ tlProgressFill.style.width='0%';
911
+ }
912
+ async function renderTimeline(centerIdx){
913
+ if(!vidName) return;
914
+ loadingInd.style.display='block';
915
+ if(timelineUrls.length===0) await loadTimelineUrls();
916
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
917
+ if(maskedOnlyMode){
918
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
919
+ for(const i of idxs){ addThumb(i,'append'); }
920
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
921
+ return;
922
+ }
923
+ if(portionStart!=null && portionEnd!=null){
924
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
925
+ for(let i=s;i<e;i++){ addThumb(i,'append'); }
926
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
927
+ return;
928
+ }
929
+ await loadWindow(centerIdx ?? currentIdx);
930
+ loadingInd.style.display='none';
931
+ }
932
+ async function loadWindow(centerIdx){
933
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
934
+ const rngStart = (viewRangeStart ?? 0);
935
+ const rngEnd = (viewRangeEnd ?? frames);
936
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
937
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
938
+ const end = Math.min(rngEnd, start + chunkSize);
939
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
940
+ timelineStart = start; timelineEnd = end;
941
+ setTimeout(async ()=>{
942
+ syncTimelineWidth();
943
+ updateSelectedThumb();
944
+ await ensureThumbVisibleCentered(currentIdx);
945
+ updatePortionOverlays();
946
+ },0);
947
+ }
948
+ function addThumb(idx, place='append'){
949
+ if(thumbEls.has(idx)) return;
950
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
951
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
952
+ const img=new Image(); img.title='frame '+(idx+1);
953
+ img.src=timelineUrls[idx];
954
+ img.onerror = () => {
955
+ const fallback = prefix + `frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
956
+ img.onerror = null;
957
+ img.src = fallback;
958
+ img.onload = () => {
959
+ const nu = prefix + `thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
960
+ timelineUrls[idx] = nu;
961
+ img.src = nu;
962
+ img.onload = null;
963
+ };
964
+ };
965
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
966
+ img.onclick=async ()=>{
967
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
968
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
969
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
970
+ };
971
+ wrap.appendChild(img);
972
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
973
+ wrap.appendChild(label);
974
+ if(place==='append'){ tlBox.appendChild(wrap); }
975
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
976
+ else{ tlBox.appendChild(wrap); }
977
+ thumbEls.set(idx, wrap);
978
+ }
979
+ tlBox.addEventListener('scroll', ()=>{
980
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
981
+ updatePlayhead(); updatePortionOverlays();
982
+ return;
983
+ }
984
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
985
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
986
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
987
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
988
+ timelineEnd = newEnd;
989
+ }
990
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
991
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
992
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
993
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
994
+ timelineStart = newStart;
995
+ }
996
+ updatePlayhead(); updatePortionOverlays();
997
+ });
998
+ isolerBoucle.onclick = async ()=>{
999
+ const start = parseInt(goFrame.value || '1',10) - 1;
1000
+ const end = parseInt(endPortion.value || '',10);
1001
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
1002
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
1003
+ portionStart = start; portionEnd = end;
1004
+ viewRangeStart = start; viewRangeEnd = end;
1005
+ player.pause(); isPaused = true;
1006
+ currentIdx = start; player.currentTime = idxToSec(start);
1007
+ await renderTimeline(currentIdx);
1008
+ resetFull.style.display = 'inline-block';
1009
+ startLoop(); updatePortionOverlays();
1010
+ };
1011
+ function startLoop(){
1012
+ if(loopInterval) clearInterval(loopInterval);
1013
+ if(portionEnd != null){
1014
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
1015
+ }
1016
+ }
1017
+ resetFull.onclick = async ()=>{
1018
+ portionStart = null; portionEnd = null;
1019
+ viewRangeStart = 0; viewRangeEnd = frames;
1020
+ goFrame.value = 1; endPortion.value = '';
1021
+ player.pause(); isPaused = true;
1022
+ await renderTimeline(currentIdx);
1023
+ resetFull.style.display='none';
1024
+ clearInterval(loopInterval); updatePortionOverlays();
1025
+ };
1026
+ function attachHandleDrag(handle, which){
1027
+ let draggingH=false;
1028
+ function onMove(e){
1029
+ if(!draggingH) return;
1030
+ const idx = nearestFrameIdxFromClientX(e.clientX);
1031
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
1032
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
1033
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
1034
+ updatePortionOverlays();
1035
+ }
1036
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
1037
+ window.addEventListener('mousemove', onMove);
1038
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
1039
+ }
1040
+ ensureOverlays(); attachHandleDrag(inHandle(), 'in'); attachHandleDrag(outHandle(), 'out');
1041
+ async function showProgress(vidStem){
1042
+ popup.style.display = 'block';
1043
+ const interval = setInterval(async () => {
1044
+ const r = await fetch('/progress/' + vidStem);
1045
+ const d = await r.json();
1046
+ tlProgressFill.style.width = d.percent + '%';
1047
+ popupProgressFill.style.width = d.percent + '%';
1048
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
1049
+ if(d.done){
1050
+ clearInterval(interval);
1051
+ popup.style.display = 'none';
1052
+ await renderTimeline(currentIdx);
1053
+ }
1054
+ }, 800);
1055
+ }
1056
+ async function loadVideoAndMeta() {
1057
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1058
+ vidStem = fileStem(vidName); bustToken = Date.now();
1059
+ const bust = Date.now();
1060
+ srcEl.src = prefix + 'data/' + encodeURIComponent(vidName) + '?t=' + bust;
1061
+ player.setAttribute('poster', prefix + 'poster/' + encodeURIComponent(vidName) + '?t=' + bust);
1062
+ player.load();
1063
+ fitCanvas();
1064
+ statusEl.textContent = 'Chargement vidéo…';
1065
+ try{
1066
+ const r=await fetch(prefix + 'meta/' + encodeURIComponent(vidName));
1067
+ if(r.ok){
1068
+ const m=await r.json();
1069
+ fps=m.fps||30; frames=m.frames||0;
1070
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
1071
+ viewRangeStart = 0; viewRangeEnd = frames;
1072
+ await loadTimelineUrls();
1073
+ await loadMasks();
1074
+ currentIdx = 0; player.currentTime = 0;
1075
+ await renderTimeline(0);
1076
+ showProgress(vidStem);
1077
+ }else{
1078
+ statusEl.textContent = 'Erreur meta';
1079
+ }
1080
+ }catch(err){
1081
+ statusEl.textContent = 'Erreur réseau meta';
1082
+ }
1083
+ }
1084
+ player.addEventListener('loadedmetadata', async ()=>{
1085
+ fitCanvas();
1086
+ if(!frames || frames<=0){
1087
+ try{ const r=await fetch(prefix + 'meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
1088
+ }
1089
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rects=[]; draw();
1090
+ });
1091
  window.addEventListener('resize', ()=>{ fitCanvas(); });
1092
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1093
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1094
+ player.addEventListener('timeupdate', ()=>{
1095
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1096
+ currentIdx=timeToIdx(player.currentTime);
1097
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1098
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
1099
+ if(followMode && !isPaused){
1100
+ const now = Date.now();
1101
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1102
+ }
1103
+ });
1104
+ goFrame.addEventListener('change', async ()=>{
1105
+ if(!vidName) return;
1106
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
1107
+ player.pause(); isPaused = true;
1108
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1109
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1110
+ await renderTimeline(currentIdx);
1111
+ await ensureThumbVisibleCentered(currentIdx);
1112
+ });
1113
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1114
+ btnFilterMasked.onclick = async ()=>{
1115
+ maskedOnlyMode = !maskedOnlyMode;
1116
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1117
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1118
+ await renderTimeline(currentIdx);
1119
+ await ensureThumbVisibleCentered(currentIdx);
1120
+ };
1121
  zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1122
+ async function gotoFrameNum(){
1123
+ const v = parseInt(gotoInput.value||'',10);
1124
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1125
+ player.pause(); isPaused = true;
1126
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1127
+ goFrame.value = v;
1128
+ await renderTimeline(currentIdx);
1129
+ await ensureThumbVisibleCentered(currentIdx);
1130
+ }
1131
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1132
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
 
 
1133
  const uploadZone = document.getElementById('uploadForm');
1134
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1135
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1136
+ uploadZone.addEventListener('drop', (e) => {
1137
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1138
+ const file = e.dataTransfer.files[0];
1139
+ if(file && file.type.startsWith('video/')){
1140
+ const fd = new FormData(); fd.append('file', file);
1141
+ fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1142
+ }
1143
+ });
1144
+ document.getElementById('exportBtn').onclick = () => { alert('Fonctionnalité export IA en développement !'); };
1145
+ async function loadFiles(){
1146
+ const r=await fetch('/files'); const d=await r.json();
1147
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1148
+ fileList.innerHTML='';
1149
+ d.items.forEach(name=>{
1150
+ const li=document.createElement('li');
1151
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1152
+ delBtn.onclick=async()=>{
1153
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1154
+ await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1155
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1156
+ };
1157
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1158
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1159
+ });
1160
+ }
1161
+ async function loadMasks(){
1162
+ loadingInd.style.display='block';
1163
+ const box=document.getElementById('maskList');
1164
+ const r=await fetch(prefix + 'mask/'+encodeURIComponent(vidName));
1165
+ const d=await r.json();
1166
+ masks=d.masks||[];
1167
+ maskedSet = new Set();
1168
+ rectMap.clear();
1169
+ masks.forEach(m=>{
1170
+ const idx = m.frame_idx;
1171
+ maskedSet.add(idx);
1172
+ if(!rectMap.has(idx)) rectMap.set(idx, []);
1173
+ if(m.shape==='rect'){
1174
+ const [x1,y1,x2,y2] = m.points;
1175
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1176
+ rectMap.get(idx).push({x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color, id:m.id, note:m.note});
1177
+ }
1178
+ });
1179
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1180
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1181
+ box.innerHTML='';
1182
+ const ul=document.createElement('ul'); ul.className='clean';
1183
+ masks.forEach(m=>{
1184
+ const li=document.createElement('li');
1185
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1186
+ const t=(m.time_s||0).toFixed(2);
1187
+ const col=m.color||'#10b981';
1188
+ const label=m.note||(`frame ${fr}`);
1189
+ 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`;
1190
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1191
+ renameBtn.onclick=async()=>{
1192
+ const nv=prompt('Nouveau nom du masque :', label);
1193
+ if(nv===null) return;
1194
+ const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1195
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1196
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1197
+ }
1198
+ };
1199
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1200
+ delMaskBtn.onclick=async()=>{
1201
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1202
+ const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1203
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1204
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1205
+ }
1206
+ };
1207
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1208
+ });
1209
+ box.appendChild(ul);
1210
+ loadingInd.style.display='none';
1211
+ if(mode === 'edit'){ loadMultiMasks(); loadAutoRects(); }
1212
+ }
1213
+ function loadMultiMasks(){
1214
+ multiMaskList.innerHTML = '';
1215
+ const curRects = rectMap.get(currentIdx) || rects; // use saved or current
1216
+ if(!curRects.length){ multiMaskList.textContent = 'Aucun masque pour cette frame'; return; }
1217
+ const ul = document.createElement('ul'); ul.className='clean';
1218
+ curRects.forEach((r, i) => {
1219
+ const li = document.createElement('li');
1220
+ li.innerHTML = `<span style="background:${r.color};width:10px;height:10px;border-radius:50%"></span> Masque ${i+1}`;
1221
+ const del = document.createElement('span'); del.className='delete-btn'; del.textContent='❌';
1222
+ del.onclick = () => { rects.splice(i,1); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
1223
+ li.appendChild(del);
1224
+ ul.appendChild(li);
1225
+ });
1226
+ multiMaskList.appendChild(ul);
1227
+ }
1228
+ warmupBtn.onclick = async () => {
1229
+ const r = await fetch('/warmup', {method:'POST'});
1230
+ if(r.ok){
1231
+ showToast('Warm-up lancé');
1232
+ const interval = setInterval(async () => {
1233
+ const pr = await fetch('/warmup_progress');
1234
+ const d = await pr.json();
1235
+ iaProgressFill.style.width = d.percent + '%';
1236
+ iaLogs.innerHTML = d.logs.join('<br>');
1237
+ if(d.done){ clearInterval(interval); }
1238
+ }, 1000);
1239
+ } else {
1240
+ showToast('Warm-up déjà en cours ou erreur');
1241
+ }
1242
+ };
1243
+ iaPreviewBtn.onclick = () => { alert('En développement, switch GPU'); };
1244
+ const iaPollInterval = setInterval(async () => {
1245
+ if(vidName){
1246
+ const r = await fetch('/progress_ia?vid=' + encodeURIComponent(vidName));
1247
+ const d = await r.json();
1248
+ iaProgressFill.style.width = d.percent + '%';
1249
+ iaLogs.textContent = d.log;
1250
+ }
1251
+ }, 2000);
1252
+ estimateBtn.onclick = async () => {
1253
+ const r = await fetch('/estimate?vid=' + encodeURIComponent(vidName) + '&masks_count=' + masks.length);
1254
+ const d = await r.json();
1255
+ alert(`Temps: ${d.time_min}, VRAM: ${d.vram_gb}`);
1256
+ };
1257
+ async function boot(){
1258
+ const envR = await fetch('/_env');
1259
+ const envD = await envR.json();
1260
+ prefix = envD.pointer_set ? '/p/' : '';
1261
+ await flushPending();
1262
+ await loadFiles();
1263
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1264
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1265
+ if(!localStorage.getItem('tutorial_seen')){ tutorial.classList.remove('hidden'); localStorage.setItem('tutorial_seen', '1'); }
1266
+ }
1267
  boot();
 
 
1268
  const style = document.createElement('style');
1269
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1270
  document.head.appendChild(style);
1271
  </script>
1272
  </html>
1273
  """
 
1274
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1275
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1276
+ vid = v or ""
1277
+ try:
1278
+ msg = urllib.parse.unquote(msg or "")
1279
+ except Exception:
1280
+ pass
1281
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1282
+ return HTMLResponse(content=html)