FABLESLIP commited on
Commit
713cdbf
·
verified ·
1 Parent(s): 42b01ce

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +214 -116
app.py CHANGED
@@ -1,16 +1,23 @@
1
- # app.py — Video Editor API (v0.8.2)
2
- # Fixes:
3
- # - Portion rendering shows full selected range (e.g., 1→55, 70→480, 8→88)
4
- # - Accurate centering for "Aller à #" (no offset drift)
5
- # - Robust Warm-up (Hub) with safe imports + error surfacing (no silent crash)
6
- # - Multi-rectangles per frame (draw/save all, list editable)
7
- # - Undo/redo for current frame actions (buttons + logic)
8
- # - IA preview button (stub message)
9
- # - Estimation temps/VRAM (endpoint + button)
10
- # - Tutoriel masquable
11
- # - Auto-save rects (browser recovery)
12
- # - IA progression feedback (barre + logs)
13
- # Base: compatible with previous v0.5.9 routes and UI
 
 
 
 
 
 
 
14
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
15
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
16
  from fastapi.staticfiles import StaticFiles
@@ -22,17 +29,14 @@ import subprocess
22
  import shutil as _shutil
23
  import os
24
  import httpx
25
- try:
26
- from huggingface_hub import snapshot_download
27
- from huggingface_hub.utils import HfHubHTTPError
28
- except ImportError:
29
- snapshot_download = None
30
- HfHubHTTPError = Exception
31
  print("[BOOT] Video Editor API starting…")
 
32
  # --- POINTEUR DE BACKEND -----------------------------------------------------
33
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
34
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
35
  _backend_url_cache = {"url": None, "ts": 0.0}
 
36
  def get_backend_base() -> str:
37
  try:
38
  if POINTER_URL:
@@ -50,17 +54,22 @@ def get_backend_base() -> str:
50
  return FALLBACK_BASE
51
  except Exception:
52
  return FALLBACK_BASE
 
53
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
54
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
55
- app = FastAPI(title="Video Editor API", version="0.8.2")
 
 
56
  # --- DATA DIRS ----------------------------------------------------------------
57
  DATA_DIR = Path("/app/data")
58
  THUMB_DIR = DATA_DIR / "_thumbs"
59
  MASK_DIR = DATA_DIR / "_masks"
60
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
61
  p.mkdir(parents=True, exist_ok=True)
 
62
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
63
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
 
64
  # --- PROXY VERS LE BACKEND ----------------------------------------------------
65
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
66
  async def proxy_all(full_path: str, request: Request):
@@ -79,17 +88,24 @@ async def proxy_all(full_path: str, request: Request):
79
  "te","trailers","upgrade"}
80
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
81
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
 
82
  # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
83
  progress_data: Dict[str, Dict[str, Any]] = {}
 
84
  # --- HELPERS ------------------------------------------------------------------
 
85
  def _is_video(p: Path) -> bool:
86
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
 
87
  def _safe_name(name: str) -> str:
88
  return Path(name).name.replace(" ", "_")
 
89
  def _has_ffmpeg() -> bool:
90
  return _shutil.which("ffmpeg") is not None
 
91
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
92
  return f"scale=min(iw\\,{max_w}):-2"
 
93
  def _meta(video: Path):
94
  cap = cv2.VideoCapture(str(video))
95
  if not cap.isOpened():
@@ -102,6 +118,7 @@ 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
  def _frame_jpg(video: Path, idx: int) -> Path:
106
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
107
  if out.exists():
@@ -147,6 +164,7 @@ def _frame_jpg(video: Path, idx: int) -> Path:
147
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
148
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
149
  return out
 
150
  def _poster(video: Path) -> Path:
151
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
152
  if out.exists():
@@ -161,8 +179,10 @@ def _poster(video: Path) -> Path:
161
  except Exception as e:
162
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
163
  return out
 
164
  def _mask_file(vid: str) -> Path:
165
  return MASK_DIR / f"{Path(vid).name}.json"
 
166
  def _load_masks(vid: str) -> Dict[str, Any]:
167
  f = _mask_file(vid)
168
  if f.exists():
@@ -171,9 +191,27 @@ def _load_masks(vid: str) -> Dict[str, Any]:
171
  except Exception as e:
172
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
173
  return {"video": vid, "masks": []}
174
- def _save_masks(vid: str, data: Dict[str, Any]):
175
- _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  # --- THUMBS GENERATION BG -----------------------------------------------------
 
177
  def _gen_thumbs_background(video: Path, vid_stem: str):
178
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
179
  try:
@@ -250,9 +288,17 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
250
  except Exception as e:
251
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
252
  progress_data[vid_stem]['done'] = True
 
253
  # --- WARM-UP (Hub) ------------------------------------------------------------
 
 
 
 
 
 
 
254
  warmup_state: Dict[str, Any] = {
255
- "state": "idle", # idle|running|done|error
256
  "running": False,
257
  "percent": 0,
258
  "current": "",
@@ -263,6 +309,7 @@ warmup_state: Dict[str, Any] = {
263
  "finished_at": None,
264
  "last_error": "",
265
  }
 
266
  WARMUP_MODELS: List[str] = [
267
  "facebook/sam2-hiera-large",
268
  "lixiaowen/diffuEraser",
@@ -271,10 +318,12 @@ WARMUP_MODELS: List[str] = [
271
  "ByteDance/Sa2VA-4B",
272
  "wangfuyun/PCM_Weights",
273
  ]
 
274
  def _append_warmup_log(msg: str):
275
  warmup_state["log"].append(msg)
276
  if len(warmup_state["log"]) > 200:
277
  warmup_state["log"] = warmup_state["log"][-200:]
 
278
  def _do_warmup():
279
  token = os.getenv("HF_TOKEN", None)
280
  warmup_state.update({
@@ -298,12 +347,12 @@ def _do_warmup():
298
  _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
299
  except Exception as e:
300
  _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
301
- # après chaque repo
302
  warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
303
  warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
304
  except Exception as e:
305
  warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
306
  _append_warmup_log(f"❌ Warm-up erreur: {e}")
 
307
  @app.post("/warmup/start", tags=["warmup"])
308
  def warmup_start():
309
  if warmup_state.get("running"):
@@ -311,9 +360,11 @@ def warmup_start():
311
  t = threading.Thread(target=_do_warmup, daemon=True)
312
  t.start()
313
  return {"ok": True, "state": warmup_state}
 
314
  @app.get("/warmup/status", tags=["warmup"])
315
  def warmup_status():
316
  return warmup_state
 
317
  # --- API ROUTES ---------------------------------------------------------------
318
  @app.get("/", tags=["meta"])
319
  def root():
@@ -321,16 +372,20 @@ def root():
321
  "ok": True,
322
  "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"]
323
  }
 
324
  @app.get("/health", tags=["meta"])
325
  def health():
326
  return {"status": "ok"}
 
327
  @app.get("/_env", tags=["meta"])
328
  def env_info():
329
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
 
330
  @app.get("/files", tags=["io"])
331
  def files():
332
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
333
  return {"count": len(items), "items": items}
 
334
  @app.get("/meta/{vid}", tags=["io"])
335
  def video_meta(vid: str):
336
  v = DATA_DIR / vid
@@ -340,6 +395,7 @@ def video_meta(vid: str):
340
  if not m:
341
  raise HTTPException(500, "Métadonnées indisponibles")
342
  return m
 
343
  @app.post("/upload", tags=["io"])
344
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
345
  ext = (Path(file.filename).suffix or ".mp4").lower()
@@ -360,9 +416,11 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
360
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
361
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
362
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
 
363
  @app.get("/progress/{vid_stem}", tags=["io"])
364
  def progress(vid_stem: str):
365
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
 
366
  @app.delete("/delete/{vid}", tags=["io"])
367
  def delete_video(vid: str):
368
  v = DATA_DIR / vid
@@ -375,6 +433,7 @@ def delete_video(vid: str):
375
  v.unlink(missing_ok=True)
376
  print(f"[DELETE] {vid}", file=sys.stdout)
377
  return {"deleted": vid}
 
378
  @app.get("/frame_idx", tags=["io"])
379
  def frame_idx(vid: str, idx: int):
380
  v = DATA_DIR / vid
@@ -390,6 +449,7 @@ def frame_idx(vid: str, idx: int):
390
  except Exception as e:
391
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
392
  raise HTTPException(500, "Frame error")
 
393
  @app.get("/poster/{vid}", tags=["io"])
394
  def poster(vid: str):
395
  v = DATA_DIR / vid
@@ -399,6 +459,7 @@ def poster(vid: str):
399
  if p.exists():
400
  return FileResponse(str(p), media_type="image/jpeg")
401
  raise HTTPException(404, "Poster introuvable")
 
402
  @app.get("/window/{vid}", tags=["io"])
403
  def window(vid: str, center: int = 0, count: int = 21):
404
  v = DATA_DIR / vid
@@ -426,7 +487,8 @@ def window(vid: str, center: int = 0, count: int = 21):
426
  items.append({"i": i, "idx": idx, "url": url})
427
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
428
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
429
- # ----- Masques -----
 
430
  @app.post("/mask", tags=["mask"])
431
  async def save_mask(payload: Dict[str, Any] = Body(...)):
432
  vid = payload.get("vid")
@@ -435,23 +497,27 @@ async def save_mask(payload: Dict[str, Any] = Body(...)):
435
  pts = payload.get("points") or []
436
  if len(pts) != 4:
437
  raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
438
- data = _load_masks(vid)
439
- m = {
440
- "id": uuid.uuid4().hex[:10],
441
- "time_s": float(payload.get("time_s") or 0.0),
442
- "frame_idx": int(payload.get("frame_idx") or 0),
443
- "shape": "rect",
444
- "points": [float(x) for x in pts],
445
- "color": payload.get("color") or "#10b981",
446
- "note": payload.get("note") or ""
447
- }
448
- data.setdefault("masks", []).append(m)
449
- _save_masks(vid, data)
 
 
450
  print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
451
  return {"saved": True, "mask": m}
 
452
  @app.get("/mask/{vid}", tags=["mask"])
453
  def list_masks(vid: str):
454
  return _load_masks(vid)
 
455
  @app.post("/mask/rename", tags=["mask"])
456
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
457
  vid = payload.get("vid")
@@ -459,23 +525,29 @@ async def rename_mask(payload: Dict[str, Any] = Body(...)):
459
  new_note = (payload.get("note") or "").strip()
460
  if not vid or not mid:
461
  raise HTTPException(400, "vid et id requis")
462
- data = _load_masks(vid)
463
- for m in data.get("masks", []):
464
- if m.get("id") == mid:
465
- m["note"] = new_note
466
- _save_masks(vid, data)
467
- return {"ok": True}
 
 
468
  raise HTTPException(404, "Masque introuvable")
 
469
  @app.post("/mask/delete", tags=["mask"])
470
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
471
  vid = payload.get("vid")
472
  mid = payload.get("id")
473
  if not vid or not mid:
474
  raise HTTPException(400, "vid et id requis")
475
- data = _load_masks(vid)
476
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
477
- _save_masks(vid, data)
 
 
478
  return {"ok": True}
 
479
  # --- UI ----------------------------------------------------------------------
480
  HTML_TEMPLATE = r"""
481
  <!doctype html>
@@ -488,7 +560,7 @@ HTML_TEMPLATE = r"""
488
  h1{margin:0 0 8px 0}
489
  .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
490
  .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
491
- .muted{color:var(--muted);font-size:13px}
492
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
493
  .viewer{max-width:1024px;margin:0 auto; position:relative}
494
  .player-wrap{position:relative; padding-bottom: var(--controlsH);}
@@ -502,7 +574,7 @@ HTML_TEMPLATE = r"""
502
  .thumb img.sel{border-color:var(--active-border)}
503
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
504
  .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}
505
- .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
506
  .timeline.filter-masked .thumb:not(.hasmask){display:none}
507
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
508
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
@@ -512,7 +584,7 @@ HTML_TEMPLATE = r"""
512
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
513
  ul.clean{list-style:none;padding-left:0;margin:6px 0}
514
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
515
- .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
516
  .rename-btn:hover{color:#2563eb}
517
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
518
  .delete-btn:hover{color:#b91c1c}
@@ -550,7 +622,7 @@ HTML_TEMPLATE = r"""
550
  </div>
551
  <div class="card" style="margin-bottom:10px">
552
  <div id="warmupBox">
553
- <button id="warmupBtn" class="btn">⚡ Warmup modèles (Hub)</button>
554
  <div id="warmup-status">—</div>
555
  <div id="warmup-progress"><div id="warmup-fill"></div></div>
556
  </div>
@@ -670,8 +742,9 @@ const gotoBtn = document.getElementById('gotoBtn');
670
  // Warmup UI
671
  const warmBtn = document.getElementById('warmupBtn');
672
  const warmStat = document.getElementById('warmup-status');
673
- const warmBar = document.getElementById('warmup-progress').firstChild; // div inside
674
- const warmLog = document.getElementById('warmup-log');
 
675
  // State
676
  let vidName = serverVid || '';
677
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -680,8 +753,9 @@ let bustToken = Date.now();
680
  let fps = 30, frames = 0;
681
  let currentIdx = 0;
682
  let mode = 'view';
683
- let rect = null, dragging=false, sx=0, sy=0;
684
  let color = '#10b981';
 
685
  let rectMap = new Map();
686
  let masks = [];
687
  let maskedSet = new Set();
@@ -700,6 +774,7 @@ let lastCenterMs = 0;
700
  const CENTER_THROTTLE_MS = 150;
701
  const PENDING_KEY = 've_pending_masks_v1';
702
  let maskedOnlyMode = false;
 
703
  // Utils
704
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
705
  function ensureOverlays(){
@@ -721,10 +796,10 @@ function updateSelectedThumb(){
721
  }
722
  function rawCenterThumb(el){
723
  const boxRect = tlBox.getBoundingClientRect();
724
- const elRect = el.getBoundingClientRect();
725
  const current = tlBox.scrollLeft;
726
- const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
727
- const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
728
  tlBox.scrollTo({ left: target, behavior: 'auto' });
729
  }
730
  async function ensureThumbVisibleCentered(idx){
@@ -789,7 +864,6 @@ async function flushPending(){
789
  savePendingList(kept);
790
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
791
  }
792
- // Layout
793
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
794
  function fitCanvas(){
795
  const r=player.getBoundingClientRect();
@@ -812,7 +886,7 @@ function setMode(m){
812
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
813
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
814
  canvas.style.pointerEvents='auto';
815
- rect = rectMap.get(currentIdx) || null; draw();
816
  }else{
817
  player.controls = true;
818
  playerWrap.classList.remove('edit-mode');
@@ -820,31 +894,44 @@ function setMode(m){
820
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
821
  btnSave.style.display='none'; btnClear.style.display='none';
822
  canvas.style.pointerEvents='none';
823
- rect=null; draw();
824
  }
825
  }
826
  function draw(){
827
  ctx.clearRect(0,0,canvas.width,canvas.height);
828
- if(rect){
829
- const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
830
- const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
831
- ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
832
- ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
 
 
 
 
 
 
 
 
 
 
 
 
 
833
  }
834
  }
835
  canvas.addEventListener('mousedown',(e)=>{
836
  if(mode!=='edit' || !vidName) return;
837
  dragging=true; const r=canvas.getBoundingClientRect();
838
  sx=e.clientX-r.left; sy=e.clientY-r.top;
839
- rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
840
  });
841
  canvas.addEventListener('mousemove',(e)=>{
842
  if(!dragging) return;
843
  const r=canvas.getBoundingClientRect();
844
- rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
845
  });
846
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
847
- btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
848
  btnEdit.onclick =()=> setMode('edit');
849
  btnBack.onclick =()=> setMode('view');
850
  // Palette
@@ -853,7 +940,7 @@ palette.querySelectorAll('.swatch').forEach(el=>{
853
  el.onclick=()=>{
854
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
855
  el.classList.add('sel'); color=el.dataset.c;
856
- if(rect){ rect.color=color; draw(); }
857
  };
858
  });
859
  // === Timeline ===
@@ -876,15 +963,15 @@ async function renderTimeline(centerIdx){
876
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
877
  return;
878
  }
879
- if (portionStart!=null && portionEnd==null){
880
  const s = Math.max(0, Math.min(portionStart, frames-1));
881
  const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
882
  tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
883
- for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
884
- timelineStart = s;
885
- timelineEnd = e;
886
  viewRangeStart = s;
887
- viewRangeEnd = e;
888
  setTimeout(async ()=>{
889
  syncTimelineWidth();
890
  updateSelectedThumb();
@@ -934,7 +1021,7 @@ function addThumb(idx, place='append'){
934
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
935
  img.onclick=async ()=>{
936
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
937
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
938
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
939
  };
940
  wrap.appendChild(img);
@@ -952,13 +1039,13 @@ tlBox.addEventListener('scroll', ()=>{
952
  return;
953
  }
954
  const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
955
- if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
956
- const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
957
  for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
958
  timelineEnd = newEnd;
959
  }
960
- if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
961
- const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
962
  for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
963
  tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
964
  timelineStart = newStart;
@@ -967,24 +1054,21 @@ tlBox.addEventListener('scroll', ()=>{
967
  });
968
  // Isoler & Boucle
969
  isolerBoucle.onclick = async ()=>{
970
- const start = parseInt(goFrame.value || '1',10) - 1;
971
- const end = parseInt(endPortion.value || '',10);
972
- if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
973
- if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
974
- portionStart = start; portionEnd = end;
975
- viewRangeStart = start; viewRangeEnd = end;
 
976
  player.pause(); isPaused = true;
977
  currentIdx = start; player.currentTime = idxToSec(start);
978
  await renderTimeline(currentIdx);
979
  resetFull.style.display = 'inline-block';
980
- startLoop(); updatePortionOverlays();
981
- };
982
- function startLoop(){
983
  if(loopInterval) clearInterval(loopInterval);
984
- if(portionEnd != null){
985
- loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
986
- }
987
- }
988
  resetFull.onclick = async ()=>{
989
  portionStart = null; portionEnd = null;
990
  viewRangeStart = 0; viewRangeEnd = frames;
@@ -992,7 +1076,8 @@ resetFull.onclick = async ()=>{
992
  player.pause(); isPaused = true;
993
  await renderTimeline(currentIdx);
994
  resetFull.style.display='none';
995
- clearInterval(loopInterval); updatePortionOverlays();
 
996
  };
997
  // Drag IN/OUT
998
  function attachHandleDrag(handle, which){
@@ -1010,7 +1095,7 @@ function attachHandleDrag(handle, which){
1010
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1011
  }
1012
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1013
- // Progress popup
1014
  async function showProgress(vidStem){
1015
  popup.style.display = 'block';
1016
  const interval = setInterval(async () => {
@@ -1018,7 +1103,7 @@ async function showProgress(vidStem){
1018
  const d = await r.json();
1019
  tlProgressFill.style.width = d.percent + '%';
1020
  popupProgressFill.style.width = d.percent + '%';
1021
- popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
1022
  if(d.done){
1023
  clearInterval(interval);
1024
  popup.style.display = 'none';
@@ -1060,27 +1145,28 @@ player.addEventListener('loadedmetadata', async ()=>{
1060
  if(!frames || frames<=0){
1061
  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{}
1062
  }
1063
- currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
1064
  });
1065
- window.addEventListener('resize', ()=>{ fitCanvas(); });
1066
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1067
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1068
  player.addEventListener('timeupdate', ()=>{
1069
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1070
  currentIdx=timeToIdx(player.currentTime);
1071
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1072
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1073
  if(followMode && !isPaused){
1074
  const now = Date.now();
1075
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1076
  }
1077
  });
 
1078
  goFrame.addEventListener('change', async ()=>{
1079
  if(!vidName) return;
1080
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1081
  player.pause(); isPaused = true;
1082
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1083
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1084
  await renderTimeline(currentIdx);
1085
  await ensureThumbVisibleCentered(currentIdx);
1086
  });
@@ -1100,6 +1186,7 @@ async function gotoFrameNum(){
1100
  player.pause(); isPaused = true;
1101
  currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1102
  goFrame.value = v;
 
1103
  await renderTimeline(currentIdx);
1104
  await ensureThumbVisibleCentered(currentIdx);
1105
  }
@@ -1144,15 +1231,17 @@ async function loadMasks(){
1144
  masks=d.masks||[];
1145
  maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1146
  rectMap.clear();
 
1147
  masks.forEach(m=>{
1148
  if(m.shape==='rect'){
1149
- const [x1,y1,x2,y2] = m.points;
1150
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
1151
- rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
 
1152
  }
1153
  });
1154
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1155
- if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1156
  box.innerHTML='';
1157
  const ul=document.createElement('ul'); ul.className='clean';
1158
  masks.forEach(m=>{
@@ -1168,7 +1257,9 @@ async function loadMasks(){
1168
  if(nv===null) return;
1169
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1170
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1171
- const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
 
 
1172
  }
1173
  };
1174
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
@@ -1176,36 +1267,42 @@ async function loadMasks(){
1176
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1177
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1178
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1179
- const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
 
 
1180
  }
1181
  };
1182
  li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1183
  });
1184
  box.appendChild(ul);
1185
  loadingInd.style.display='none';
 
1186
  }
1187
- // Save mask (+ cache)
1188
  btnSave.onclick = async ()=>{
1189
- if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1190
  const defaultName = `frame ${currentIdx+1}`;
1191
  const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1192
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
1193
- const x=Math.min(rect.x1,rect.x2)/normW;
1194
- const y=Math.min(rect.y1,rect.y2)/normH;
1195
- const w=Math.abs(rect.x2-rect.x1)/normW;
1196
- const h=Math.abs(rect.y2-rect.y1)/normH;
1197
- const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:rect.color||color,note:note};
1198
  addPending(payload);
1199
  try{
1200
  const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1201
  if(r.ok){
1202
  const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1203
  savePendingList(lst);
1204
- rectMap.set(currentIdx,{...rect});
 
 
 
1205
  await loadMasks(); await renderTimeline(currentIdx);
1206
  showToast('Masque enregistré ✅');
1207
  } else {
1208
- const txt = await r.text();
 
1209
  alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1210
  }
1211
  }catch(e){
@@ -1222,7 +1319,7 @@ warmBtn.onclick = async ()=>{
1222
  warmBar.style.width = (st.percent||0) + '%';
1223
  let txt = (st.state||'idle');
1224
  if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1225
- if(st.state==='error'){ txt += ' · erreur'; warmLog.classList.add('err'); } else { warmLog.classList.remove('err'); }
1226
  warmStat.textContent = txt;
1227
  const lines = (st.log||[]).slice(-6);
1228
  warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
@@ -1246,6 +1343,7 @@ document.head.appendChild(style);
1246
  </script>
1247
  </html>
1248
  """
 
1249
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1250
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1251
  vid = v or ""
@@ -1254,4 +1352,4 @@ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1254
  except Exception:
1255
  pass
1256
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1257
- return HTMLResponse(content=html)
 
1
+ # app.py — Video Editor API (v0.8.2 + multi-rect + atomic mask save)
2
+ # Ajouts sur ta v0.8.2 (sans rien casser) :
3
+ # - MULTI-RECT : plusieurs rectangles par frame (UI + sauvegarde) sans écraser les précédents
4
+ # - Sauvegarde ATOMIQUE des masques + verrou fichier -> évite les erreurs 500 et JSON corrompu
5
+ # - Pop-up d'erreur propre (si le backend renvoie une page HTML, on affiche un message court)
6
+ #
7
+ # Dépendances (inchangées) :
8
+ # fastapi==0.115.5
9
+ # uvicorn[standard]==0.30.6
10
+ # python-multipart==0.0.9
11
+ # aiofiles==23.2.1
12
+ # starlette==0.37.2
13
+ # numpy==1.26.4
14
+ # opencv-python-headless==4.10.0.84
15
+ # pillow==10.4.0
16
+ # huggingface_hub==0.23.5
17
+ # transformers==4.44.2
18
+ # torch==2.4.0
19
+ # joblib==1.4.2
20
+
21
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
22
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
23
  from fastapi.staticfiles import StaticFiles
 
29
  import shutil as _shutil
30
  import os
31
  import httpx
32
+
 
 
 
 
 
33
  print("[BOOT] Video Editor API starting…")
34
+
35
  # --- POINTEUR DE BACKEND -----------------------------------------------------
36
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
37
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
38
  _backend_url_cache = {"url": None, "ts": 0.0}
39
+
40
  def get_backend_base() -> str:
41
  try:
42
  if POINTER_URL:
 
54
  return FALLBACK_BASE
55
  except Exception:
56
  return FALLBACK_BASE
57
+
58
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
59
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
60
+
61
+ app = FastAPI(title="Video Editor API", version="0.8.2-mr")
62
+
63
  # --- DATA DIRS ----------------------------------------------------------------
64
  DATA_DIR = Path("/app/data")
65
  THUMB_DIR = DATA_DIR / "_thumbs"
66
  MASK_DIR = DATA_DIR / "_masks"
67
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
68
  p.mkdir(parents=True, exist_ok=True)
69
+
70
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
71
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
72
+
73
  # --- PROXY VERS LE BACKEND ----------------------------------------------------
74
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
75
  async def proxy_all(full_path: str, request: Request):
 
88
  "te","trailers","upgrade"}
89
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
90
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
91
+
92
  # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
93
  progress_data: Dict[str, Dict[str, Any]] = {}
94
+
95
  # --- HELPERS ------------------------------------------------------------------
96
+
97
  def _is_video(p: Path) -> bool:
98
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
99
+
100
  def _safe_name(name: str) -> str:
101
  return Path(name).name.replace(" ", "_")
102
+
103
  def _has_ffmpeg() -> bool:
104
  return _shutil.which("ffmpeg") is not None
105
+
106
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
107
  return f"scale=min(iw\\,{max_w}):-2"
108
+
109
  def _meta(video: Path):
110
  cap = cv2.VideoCapture(str(video))
111
  if not cap.isOpened():
 
118
  cap.release()
119
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
120
  return {"frames": frames, "fps": fps, "w": w, "h": h}
121
+
122
  def _frame_jpg(video: Path, idx: int) -> Path:
123
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
124
  if out.exists():
 
164
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
165
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
166
  return out
167
+
168
  def _poster(video: Path) -> Path:
169
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
170
  if out.exists():
 
179
  except Exception as e:
180
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
181
  return out
182
+
183
  def _mask_file(vid: str) -> Path:
184
  return MASK_DIR / f"{Path(vid).name}.json"
185
+
186
  def _load_masks(vid: str) -> Dict[str, Any]:
187
  f = _mask_file(vid)
188
  if f.exists():
 
191
  except Exception as e:
192
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
193
  return {"video": vid, "masks": []}
194
+
195
+ # --- Sauvegarde atomique + verrou -------------------------------------------
196
+ _mask_locks: Dict[str, threading.Lock] = {}
197
+
198
+ def _get_mask_lock(vid: str) -> threading.Lock:
199
+ key = str(_mask_file(vid))
200
+ lock = _mask_locks.get(key)
201
+ if not lock:
202
+ lock = threading.Lock()
203
+ _mask_locks[key] = lock
204
+ return lock
205
+
206
+ def _save_masks_atomic(vid: str, data: Dict[str, Any]):
207
+ f = _mask_file(vid)
208
+ tmp = f.with_suffix(f.suffix + ".tmp")
209
+ txt = json.dumps(data, ensure_ascii=False, indent=2)
210
+ tmp.write_text(txt, encoding="utf-8")
211
+ os.replace(tmp, f) # atomic sur la plupart des FS
212
+
213
  # --- THUMBS GENERATION BG -----------------------------------------------------
214
+
215
  def _gen_thumbs_background(video: Path, vid_stem: str):
216
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
217
  try:
 
288
  except Exception as e:
289
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
290
  progress_data[vid_stem]['done'] = True
291
+
292
  # --- WARM-UP (Hub) ------------------------------------------------------------
293
+ from huggingface_hub import snapshot_download
294
+ try:
295
+ from huggingface_hub.utils import HfHubHTTPError # 0.13+
296
+ except Exception:
297
+ class HfHubHTTPError(Exception):
298
+ pass
299
+
300
  warmup_state: Dict[str, Any] = {
301
+ "state": "idle", # idle|running|done|error
302
  "running": False,
303
  "percent": 0,
304
  "current": "",
 
309
  "finished_at": None,
310
  "last_error": "",
311
  }
312
+
313
  WARMUP_MODELS: List[str] = [
314
  "facebook/sam2-hiera-large",
315
  "lixiaowen/diffuEraser",
 
318
  "ByteDance/Sa2VA-4B",
319
  "wangfuyun/PCM_Weights",
320
  ]
321
+
322
  def _append_warmup_log(msg: str):
323
  warmup_state["log"].append(msg)
324
  if len(warmup_state["log"]) > 200:
325
  warmup_state["log"] = warmup_state["log"][-200:]
326
+
327
  def _do_warmup():
328
  token = os.getenv("HF_TOKEN", None)
329
  warmup_state.update({
 
347
  _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
348
  except Exception as e:
349
  _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
 
350
  warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
351
  warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
352
  except Exception as e:
353
  warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
354
  _append_warmup_log(f"❌ Warm-up erreur: {e}")
355
+
356
  @app.post("/warmup/start", tags=["warmup"])
357
  def warmup_start():
358
  if warmup_state.get("running"):
 
360
  t = threading.Thread(target=_do_warmup, daemon=True)
361
  t.start()
362
  return {"ok": True, "state": warmup_state}
363
+
364
  @app.get("/warmup/status", tags=["warmup"])
365
  def warmup_status():
366
  return warmup_state
367
+
368
  # --- API ROUTES ---------------------------------------------------------------
369
  @app.get("/", tags=["meta"])
370
  def root():
 
372
  "ok": True,
373
  "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"]
374
  }
375
+
376
  @app.get("/health", tags=["meta"])
377
  def health():
378
  return {"status": "ok"}
379
+
380
  @app.get("/_env", tags=["meta"])
381
  def env_info():
382
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
383
+
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
+
389
  @app.get("/meta/{vid}", tags=["io"])
390
  def video_meta(vid: str):
391
  v = DATA_DIR / vid
 
395
  if not m:
396
  raise HTTPException(500, "Métadonnées indisponibles")
397
  return m
398
+
399
  @app.post("/upload", tags=["io"])
400
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
401
  ext = (Path(file.filename).suffix or ".mp4").lower()
 
416
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
417
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
418
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
419
+
420
  @app.get("/progress/{vid_stem}", tags=["io"])
421
  def progress(vid_stem: str):
422
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
423
+
424
  @app.delete("/delete/{vid}", tags=["io"])
425
  def delete_video(vid: str):
426
  v = DATA_DIR / vid
 
433
  v.unlink(missing_ok=True)
434
  print(f"[DELETE] {vid}", file=sys.stdout)
435
  return {"deleted": vid}
436
+
437
  @app.get("/frame_idx", tags=["io"])
438
  def frame_idx(vid: str, idx: int):
439
  v = DATA_DIR / vid
 
449
  except Exception as e:
450
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
451
  raise HTTPException(500, "Frame error")
452
+
453
  @app.get("/poster/{vid}", tags=["io"])
454
  def poster(vid: str):
455
  v = DATA_DIR / vid
 
459
  if p.exists():
460
  return FileResponse(str(p), media_type="image/jpeg")
461
  raise HTTPException(404, "Poster introuvable")
462
+
463
  @app.get("/window/{vid}", tags=["io"])
464
  def window(vid: str, center: int = 0, count: int = 21):
465
  v = DATA_DIR / vid
 
487
  items.append({"i": i, "idx": idx, "url": url})
488
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
489
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
490
+
491
+ # ----- Masques ---------------------------------------------------------------
492
  @app.post("/mask", tags=["mask"])
493
  async def save_mask(payload: Dict[str, Any] = Body(...)):
494
  vid = payload.get("vid")
 
497
  pts = payload.get("points") or []
498
  if len(pts) != 4:
499
  raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
500
+ lock = _get_mask_lock(vid)
501
+ with lock:
502
+ data = _load_masks(vid)
503
+ m = {
504
+ "id": uuid.uuid4().hex[:10],
505
+ "time_s": float(payload.get("time_s") or 0.0),
506
+ "frame_idx": int(payload.get("frame_idx") or 0),
507
+ "shape": "rect",
508
+ "points": [float(x) for x in pts], # normalisées [0..1]
509
+ "color": payload.get("color") or "#10b981",
510
+ "note": payload.get("note") or ""
511
+ }
512
+ data.setdefault("masks", []).append(m)
513
+ _save_masks_atomic(vid, data)
514
  print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
515
  return {"saved": True, "mask": m}
516
+
517
  @app.get("/mask/{vid}", tags=["mask"])
518
  def list_masks(vid: str):
519
  return _load_masks(vid)
520
+
521
  @app.post("/mask/rename", tags=["mask"])
522
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
523
  vid = payload.get("vid")
 
525
  new_note = (payload.get("note") or "").strip()
526
  if not vid or not mid:
527
  raise HTTPException(400, "vid et id requis")
528
+ lock = _get_mask_lock(vid)
529
+ with lock:
530
+ data = _load_masks(vid)
531
+ for m in data.get("masks", []):
532
+ if m.get("id") == mid:
533
+ m["note"] = new_note
534
+ _save_masks_atomic(vid, data)
535
+ return {"ok": True}
536
  raise HTTPException(404, "Masque introuvable")
537
+
538
  @app.post("/mask/delete", tags=["mask"])
539
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
540
  vid = payload.get("vid")
541
  mid = payload.get("id")
542
  if not vid or not mid:
543
  raise HTTPException(400, "vid et id requis")
544
+ lock = _get_mask_lock(vid)
545
+ with lock:
546
+ data = _load_masks(vid)
547
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
548
+ _save_masks_atomic(vid, data)
549
  return {"ok": True}
550
+
551
  # --- UI ----------------------------------------------------------------------
552
  HTML_TEMPLATE = r"""
553
  <!doctype html>
 
560
  h1{margin:0 0 8px 0}
561
  .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
562
  .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
563
+ .muted{color:#64748b;font-size:13px}
564
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
565
  .viewer{max-width:1024px;margin:0 auto; position:relative}
566
  .player-wrap{position:relative; padding-bottom: var(--controlsH);}
 
574
  .thumb img.sel{border-color:var(--active-border)}
575
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
576
  .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}
577
+ .thumb-label{font-size:11px;color:#64748b;margin-top:2px;display:block}
578
  .timeline.filter-masked .thumb:not(.hasmask){display:none}
579
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
580
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
 
584
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
585
  ul.clean{list-style:none;padding-left:0;margin:6px 0}
586
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
587
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:#64748b;transition:color 0.2s}
588
  .rename-btn:hover{color:#2563eb}
589
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
590
  .delete-btn:hover{color:#b91c1c}
 
622
  </div>
623
  <div class="card" style="margin-bottom:10px">
624
  <div id="warmupBox">
625
+ <button id="warmupBtn" class="btn">⚡ Warm-up modèles (Hub)</button>
626
  <div id="warmup-status">—</div>
627
  <div id="warmup-progress"><div id="warmup-fill"></div></div>
628
  </div>
 
742
  // Warmup UI
743
  const warmBtn = document.getElementById('warmupBtn');
744
  const warmStat = document.getElementById('warmup-status');
745
+ const warmBar = document.getElementById('warmup-fill');
746
+ const warmLog = document.getElementById('warmup-log');
747
+
748
  // State
749
  let vidName = serverVid || '';
750
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
 
753
  let fps = 30, frames = 0;
754
  let currentIdx = 0;
755
  let mode = 'view';
756
+ let rectDraft = null, dragging=false, sx=0, sy=0; // rectangle en cours (px)
757
  let color = '#10b981';
758
+ // rectMap: frame_idx -> [{nx1,ny1,nx2,ny2,color}]
759
  let rectMap = new Map();
760
  let masks = [];
761
  let maskedSet = new Set();
 
774
  const CENTER_THROTTLE_MS = 150;
775
  const PENDING_KEY = 've_pending_masks_v1';
776
  let maskedOnlyMode = false;
777
+
778
  // Utils
779
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
780
  function ensureOverlays(){
 
796
  }
797
  function rawCenterThumb(el){
798
  const boxRect = tlBox.getBoundingClientRect();
799
+ const elRect = el.getBoundingClientRect();
800
  const current = tlBox.scrollLeft;
801
+ const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
802
+ const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
803
  tlBox.scrollTo({ left: target, behavior: 'auto' });
804
  }
805
  async function ensureThumbVisibleCentered(idx){
 
864
  savePendingList(kept);
865
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
866
  }
 
867
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
868
  function fitCanvas(){
869
  const r=player.getBoundingClientRect();
 
886
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
887
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
888
  canvas.style.pointerEvents='auto';
889
+ rectDraft = null; draw();
890
  }else{
891
  player.controls = true;
892
  playerWrap.classList.remove('edit-mode');
 
894
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
895
  btnSave.style.display='none'; btnClear.style.display='none';
896
  canvas.style.pointerEvents='none';
897
+ rectDraft=null; draw();
898
  }
899
  }
900
  function draw(){
901
  ctx.clearRect(0,0,canvas.width,canvas.height);
902
+ // Rectangles sauvegardés pour la frame courante (normalisés -> px)
903
+ const arr = rectMap.get(currentIdx) || [];
904
+ for(const r of arr){
905
+ const x1 = r.nx1 * canvas.width;
906
+ const y1 = r.ny1 * canvas.height;
907
+ const x2 = r.nx2 * canvas.width;
908
+ const y2 = r.ny2 * canvas.height;
909
+ const x=Math.min(x1,x2), y=Math.min(y1,y2);
910
+ const w=Math.abs(x2-x1), h=Math.abs(y2-y1);
911
+ ctx.strokeStyle=r.color||'#10b981'; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
912
+ ctx.fillStyle=(r.color||'#10b981')+'28'; ctx.fillRect(x,y,w,h);
913
+ }
914
+ // Rectangle en cours ("draft") en px
915
+ if(rectDraft){
916
+ const x=Math.min(rectDraft.x1,rectDraft.x2), y=Math.min(rectDraft.y1,rectDraft.y2);
917
+ const w=Math.abs(rectDraft.x2-rectDraft.x1), h=Math.abs(rectDraft.y2-rectDraft.y1);
918
+ ctx.strokeStyle=rectDraft.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
919
+ ctx.fillStyle=(rectDraft.color||color)+'28'; ctx.fillRect(x,y,w,h);
920
  }
921
  }
922
  canvas.addEventListener('mousedown',(e)=>{
923
  if(mode!=='edit' || !vidName) return;
924
  dragging=true; const r=canvas.getBoundingClientRect();
925
  sx=e.clientX-r.left; sy=e.clientY-r.top;
926
+ rectDraft={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
927
  });
928
  canvas.addEventListener('mousemove',(e)=>{
929
  if(!dragging) return;
930
  const r=canvas.getBoundingClientRect();
931
+ rectDraft.x2=e.clientX-r.left; rectDraft.y2=e.clientY-r.top; draw();
932
  });
933
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
934
+ btnClear.onclick=()=>{ rectDraft=null; draw(); };
935
  btnEdit.onclick =()=> setMode('edit');
936
  btnBack.onclick =()=> setMode('view');
937
  // Palette
 
940
  el.onclick=()=>{
941
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
942
  el.classList.add('sel'); color=el.dataset.c;
943
+ if(rectDraft){ rectDraft.color=color; draw(); }
944
  };
945
  });
946
  // === Timeline ===
 
963
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
964
  return;
965
  }
966
+ if (portionStart!=null && portionEnd!=null){
967
  const s = Math.max(0, Math.min(portionStart, frames-1));
968
  const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
969
  tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
970
+ for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
971
+ timelineStart = s;
972
+ timelineEnd = e;
973
  viewRangeStart = s;
974
+ viewRangeEnd = e;
975
  setTimeout(async ()=>{
976
  syncTimelineWidth();
977
  updateSelectedThumb();
 
1021
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
1022
  img.onclick=async ()=>{
1023
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
1024
+ rectDraft = null; draw();
1025
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
1026
  };
1027
  wrap.appendChild(img);
 
1039
  return;
1040
  }
1041
  const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
1042
+ if (scrollWidth - scrollLeft - clientWidth < 100 && timelineEnd < viewRangeEnd){
1043
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + 50);
1044
  for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
1045
  timelineEnd = newEnd;
1046
  }
1047
+ if (scrollLeft < 100 && timelineStart > viewRangeStart){
1048
+ const newStart = Math.max(viewRangeStart, timelineStart - 50);
1049
  for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
1050
  tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
1051
  timelineStart = newStart;
 
1054
  });
1055
  // Isoler & Boucle
1056
  isolerBoucle.onclick = async ()=>{
1057
+ const start = Math.max(0, parseInt(goFrame.value || '1',10) - 1);
1058
+ const endIn = parseInt(endPortion.value || '',10);
1059
+ if(!endPortion.value || endIn <= (start+1) || endIn > frames){ alert('Portion invalide (fin > début)'); return; }
1060
+ const endExclusive = Math.min(frames, endIn); // fin exclusive; "55" => inclut #55 (idx 54)
1061
+ portionStart = start;
1062
+ portionEnd = endExclusive;
1063
+ viewRangeStart = start; viewRangeEnd = endExclusive;
1064
  player.pause(); isPaused = true;
1065
  currentIdx = start; player.currentTime = idxToSec(start);
1066
  await renderTimeline(currentIdx);
1067
  resetFull.style.display = 'inline-block';
 
 
 
1068
  if(loopInterval) clearInterval(loopInterval);
1069
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
1070
+ updatePortionOverlays();
1071
+ };
 
1072
  resetFull.onclick = async ()=>{
1073
  portionStart = null; portionEnd = null;
1074
  viewRangeStart = 0; viewRangeEnd = frames;
 
1076
  player.pause(); isPaused = true;
1077
  await renderTimeline(currentIdx);
1078
  resetFull.style.display='none';
1079
+ if(loopInterval) clearInterval(loopInterval);
1080
+ updatePortionOverlays();
1081
  };
1082
  // Drag IN/OUT
1083
  function attachHandleDrag(handle, which){
 
1095
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1096
  }
1097
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1098
+ // Progress popup (thumbs)
1099
  async function showProgress(vidStem){
1100
  popup.style.display = 'block';
1101
  const interval = setInterval(async () => {
 
1103
  const d = await r.json();
1104
  tlProgressFill.style.width = d.percent + '%';
1105
  popupProgressFill.style.width = d.percent + '%';
1106
+ popupLogs.innerHTML = (d.logs||[]).map(x=>String(x)).join('<br>');
1107
  if(d.done){
1108
  clearInterval(interval);
1109
  popup.style.display = 'none';
 
1145
  if(!frames || frames<=0){
1146
  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{}
1147
  }
1148
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rectDraft=null; draw();
1149
  });
1150
+ window.addEventListener('resize', ()=>{ fitCanvas(); draw(); });
1151
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1152
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1153
  player.addEventListener('timeupdate', ()=>{
1154
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1155
  currentIdx=timeToIdx(player.currentTime);
1156
+ draw();
1157
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1158
  if(followMode && !isPaused){
1159
  const now = Date.now();
1160
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1161
  }
1162
  });
1163
+
1164
  goFrame.addEventListener('change', async ()=>{
1165
  if(!vidName) return;
1166
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1167
  player.pause(); isPaused = true;
1168
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1169
+ rectDraft = null; draw();
1170
  await renderTimeline(currentIdx);
1171
  await ensureThumbVisibleCentered(currentIdx);
1172
  });
 
1186
  player.pause(); isPaused = true;
1187
  currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1188
  goFrame.value = v;
1189
+ rectDraft = null; draw();
1190
  await renderTimeline(currentIdx);
1191
  await ensureThumbVisibleCentered(currentIdx);
1192
  }
 
1231
  masks=d.masks||[];
1232
  maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1233
  rectMap.clear();
1234
+ // MULTI-RECT : on cumule tous les rectangles de chaque frame (coords normalisées)
1235
  masks.forEach(m=>{
1236
  if(m.shape==='rect'){
1237
+ const [nx1,ny1,nx2,ny2] = m.points;
1238
+ const arr = rectMap.get(m.frame_idx) || [];
1239
+ arr.push({nx1,ny1,nx2,ny2,color:m.color});
1240
+ rectMap.set(m.frame_idx, arr);
1241
  }
1242
  });
1243
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1244
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; draw(); return; }
1245
  box.innerHTML='';
1246
  const ul=document.createElement('ul'); ul.className='clean';
1247
  masks.forEach(m=>{
 
1257
  if(nv===null) return;
1258
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1259
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1260
+ let txt = await rr.text(); const ct = rr.headers.get('content-type')||'';
1261
+ if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1262
+ alert('Échec renommage: ' + rr.status + ' ' + txt);
1263
  }
1264
  };
1265
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
 
1267
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1268
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1269
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1270
+ let txt = await rr.text(); const ct = rr.headers.get('content-type')||'';
1271
+ if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1272
+ alert('Échec suppression: ' + rr.status + ' ' + txt);
1273
  }
1274
  };
1275
  li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1276
  });
1277
  box.appendChild(ul);
1278
  loadingInd.style.display='none';
1279
+ draw();
1280
  }
1281
+ // Save mask (+ cache) — MULTI-RECT
1282
  btnSave.onclick = async ()=>{
1283
+ if(!rectDraft || !vidName){ alert('Aucune sélection.'); return; }
1284
  const defaultName = `frame ${currentIdx+1}`;
1285
  const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1286
+ const nx1 = Math.min(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1287
+ const ny1 = Math.min(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1288
+ const nx2 = Math.max(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1289
+ const ny2 = Math.max(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1290
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[nx1,ny1,nx2,ny2],color:rectDraft.color||color,note:note};
 
1291
  addPending(payload);
1292
  try{
1293
  const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1294
  if(r.ok){
1295
  const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1296
  savePendingList(lst);
1297
+ const arr = rectMap.get(currentIdx) || [];
1298
+ arr.push({nx1,ny1,nx2,ny2,color:payload.color});
1299
+ rectMap.set(currentIdx, arr);
1300
+ rectDraft = null;
1301
  await loadMasks(); await renderTimeline(currentIdx);
1302
  showToast('Masque enregistré ✅');
1303
  } else {
1304
+ let txt = await r.text(); const ct = r.headers.get('content-type')||'';
1305
+ if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1306
  alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1307
  }
1308
  }catch(e){
 
1319
  warmBar.style.width = (st.percent||0) + '%';
1320
  let txt = (st.state||'idle');
1321
  if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1322
+ if(st.state==='error'){ txt += ' · erreur'; }
1323
  warmStat.textContent = txt;
1324
  const lines = (st.log||[]).slice(-6);
1325
  warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
 
1343
  </script>
1344
  </html>
1345
  """
1346
+
1347
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1348
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1349
  vid = v or ""
 
1352
  except Exception:
1353
  pass
1354
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1355
+ return HTMLResponse(content=html)