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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +114 -226
app.py CHANGED
@@ -1,32 +1,16 @@
1
- # We'll write the corrected full script (based on the user's v0.8.2) to a file for download.
2
- # Changes:
3
- # - Frontend: allow multiple rectangles per frame (rectMap: Map<frameIdx, Array of normalized rects>).
4
- # - Frontend: keep "draft" rect separate; btnClear only clears draft, doesn't remove saved.
5
- # - Frontend: draw() renders all saved rects for the current frame + the draft.
6
- # - Backend: atomic mask JSON writes with file-level locks to avoid 500 errors on concurrent saves.
7
- # - Backend: use os.replace for atomic write; wrap /mask, /mask/rename, /mask/delete operations with the lock.
8
- # - Everything else (timeline, warm-up, routes) stays as in v0.8.2.
9
- from pathlib import Path
10
- code = r'''# app.py Video Editor API (v0.8.2+multi-rect, atomic-mask)
11
- # Fixes over v0.8.2:
12
- # - MULTI-RECT: plusieurs rectangles par frame (UI + sauvegarde), sans écraser les précédents
13
- # - Enregistrement masques ATOMIQUE + verrou par fichier (supprime les 500 sporadiques)
14
- # - Ne casse rien d’existant : timeline/portion/goto restent identiques à v0.8.2
15
- #
16
- # Dépendances (comme fourni) :
17
- # fastapi==0.115.5
18
- # uvicorn[standard]==0.30.6
19
- # python-multipart==0.0.9
20
- # aiofiles==23.2.1
21
- # starlette==0.37.2
22
- # numpy==1.26.4
23
- # opencv-python-headless==4.10.0.84
24
- # pillow==10.4.0
25
- # huggingface_hub==0.23.5
26
- # transformers==4.44.2
27
- # torch==2.4.0
28
- # joblib==1.4.2
29
-
30
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
31
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
32
  from fastapi.staticfiles import StaticFiles
@@ -38,14 +22,17 @@ import subprocess
38
  import shutil as _shutil
39
  import os
40
  import httpx
41
-
 
 
 
 
 
42
  print("[BOOT] Video Editor API starting…")
43
-
44
  # --- POINTEUR DE BACKEND -----------------------------------------------------
45
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
46
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
47
  _backend_url_cache = {"url": None, "ts": 0.0}
48
-
49
  def get_backend_base() -> str:
50
  try:
51
  if POINTER_URL:
@@ -63,22 +50,17 @@ def get_backend_base() -> str:
63
  return FALLBACK_BASE
64
  except Exception:
65
  return FALLBACK_BASE
66
-
67
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
68
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
69
-
70
- app = FastAPI(title="Video Editor API", version="0.8.2-mr")
71
-
72
  # --- DATA DIRS ----------------------------------------------------------------
73
  DATA_DIR = Path("/app/data")
74
  THUMB_DIR = DATA_DIR / "_thumbs"
75
  MASK_DIR = DATA_DIR / "_masks"
76
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
77
  p.mkdir(parents=True, exist_ok=True)
78
-
79
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
80
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
81
-
82
  # --- PROXY VERS LE BACKEND ----------------------------------------------------
83
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
84
  async def proxy_all(full_path: str, request: Request):
@@ -97,24 +79,17 @@ async def proxy_all(full_path: str, request: Request):
97
  "te","trailers","upgrade"}
98
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
99
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
100
-
101
  # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
102
  progress_data: Dict[str, Dict[str, Any]] = {}
103
-
104
  # --- HELPERS ------------------------------------------------------------------
105
-
106
  def _is_video(p: Path) -> bool:
107
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
108
-
109
  def _safe_name(name: str) -> str:
110
  return Path(name).name.replace(" ", "_")
111
-
112
  def _has_ffmpeg() -> bool:
113
  return _shutil.which("ffmpeg") is not None
114
-
115
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
116
  return f"scale=min(iw\\,{max_w}):-2"
117
-
118
  def _meta(video: Path):
119
  cap = cv2.VideoCapture(str(video))
120
  if not cap.isOpened():
@@ -127,7 +102,6 @@ def _meta(video: Path):
127
  cap.release()
128
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
129
  return {"frames": frames, "fps": fps, "w": w, "h": h}
130
-
131
  def _frame_jpg(video: Path, idx: int) -> Path:
132
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
133
  if out.exists():
@@ -173,7 +147,6 @@ def _frame_jpg(video: Path, idx: int) -> Path:
173
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
174
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
175
  return out
176
-
177
  def _poster(video: Path) -> Path:
178
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
179
  if out.exists():
@@ -188,10 +161,8 @@ def _poster(video: Path) -> Path:
188
  except Exception as e:
189
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
190
  return out
191
-
192
  def _mask_file(vid: str) -> Path:
193
  return MASK_DIR / f"{Path(vid).name}.json"
194
-
195
  def _load_masks(vid: str) -> Dict[str, Any]:
196
  f = _mask_file(vid)
197
  if f.exists():
@@ -200,27 +171,9 @@ def _load_masks(vid: str) -> Dict[str, Any]:
200
  except Exception as e:
201
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
202
  return {"video": vid, "masks": []}
203
-
204
- # --- Atomic save with per-file locks -----------------------------------------
205
- _mask_locks: Dict[str, threading.Lock] = {}
206
-
207
- def _get_mask_lock(vid: str) -> threading.Lock:
208
- key = str(_mask_file(vid))
209
- lock = _mask_locks.get(key)
210
- if not lock:
211
- lock = threading.Lock()
212
- _mask_locks[key] = lock
213
- return lock
214
-
215
- def _save_masks_atomic(vid: str, data: Dict[str, Any]):
216
- f = _mask_file(vid)
217
- tmp = f.with_suffix(f.suffix + ".tmp")
218
- txt = json.dumps(data, ensure_ascii=False, indent=2)
219
- tmp.write_text(txt, encoding="utf-8")
220
- os.replace(tmp, f)
221
-
222
  # --- THUMBS GENERATION BG -----------------------------------------------------
223
-
224
  def _gen_thumbs_background(video: Path, vid_stem: str):
225
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
226
  try:
@@ -260,7 +213,7 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
260
  proc.wait()
261
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
262
  progress_data[vid_stem]['percent'] = 100
263
- progress_data[vid_stem]['logs"].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
264
  progress_data[vid_stem]['done'] = True
265
  print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
266
  else:
@@ -297,17 +250,9 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
297
  except Exception as e:
298
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
299
  progress_data[vid_stem]['done'] = True
300
-
301
  # --- WARM-UP (Hub) ------------------------------------------------------------
302
- from huggingface_hub import snapshot_download
303
- try:
304
- from huggingface_hub.utils import HfHubHTTPError # 0.13+
305
- except Exception:
306
- class HfHubHTTPError(Exception):
307
- pass
308
-
309
  warmup_state: Dict[str, Any] = {
310
- "state": "idle", # idle|running|done|error
311
  "running": False,
312
  "percent": 0,
313
  "current": "",
@@ -318,7 +263,6 @@ warmup_state: Dict[str, Any] = {
318
  "finished_at": None,
319
  "last_error": "",
320
  }
321
-
322
  WARMUP_MODELS: List[str] = [
323
  "facebook/sam2-hiera-large",
324
  "lixiaowen/diffuEraser",
@@ -327,12 +271,10 @@ WARMUP_MODELS: List[str] = [
327
  "ByteDance/Sa2VA-4B",
328
  "wangfuyun/PCM_Weights",
329
  ]
330
-
331
  def _append_warmup_log(msg: str):
332
  warmup_state["log"].append(msg)
333
  if len(warmup_state["log"]) > 200:
334
  warmup_state["log"] = warmup_state["log"][-200:]
335
-
336
  def _do_warmup():
337
  token = os.getenv("HF_TOKEN", None)
338
  warmup_state.update({
@@ -356,12 +298,12 @@ def _do_warmup():
356
  _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
357
  except Exception as e:
358
  _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
 
359
  warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
360
  warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
361
  except Exception as e:
362
  warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
363
  _append_warmup_log(f"❌ Warm-up erreur: {e}")
364
-
365
  @app.post("/warmup/start", tags=["warmup"])
366
  def warmup_start():
367
  if warmup_state.get("running"):
@@ -369,11 +311,9 @@ def warmup_start():
369
  t = threading.Thread(target=_do_warmup, daemon=True)
370
  t.start()
371
  return {"ok": True, "state": warmup_state}
372
-
373
  @app.get("/warmup/status", tags=["warmup"])
374
  def warmup_status():
375
  return warmup_state
376
-
377
  # --- API ROUTES ---------------------------------------------------------------
378
  @app.get("/", tags=["meta"])
379
  def root():
@@ -381,21 +321,17 @@ def root():
381
  "ok": True,
382
  "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"]
383
  }
384
-
385
- @app.get("/health", tags=["meta"])
386
  def health():
387
  return {"status": "ok"}
388
-
389
- @app.get("/_env", tags=["meta"])
390
  def env_info():
391
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
392
-
393
- @app.get("/files", tags=["io"])
394
  def files():
395
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
396
  return {"count": len(items), "items": items}
397
-
398
- @app.get("/meta/{vid}", tags=["io"])
399
  def video_meta(vid: str):
400
  v = DATA_DIR / vid
401
  if not v.exists():
@@ -404,8 +340,7 @@ def video_meta(vid: str):
404
  if not m:
405
  raise HTTPException(500, "Métadonnées indisponibles")
406
  return m
407
-
408
- @app.post("/upload", tags=["io"])
409
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
410
  ext = (Path(file.filename).suffix or ".mp4").lower()
411
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
@@ -425,12 +360,10 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
425
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
426
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
427
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
428
-
429
- @app.get("/progress/{vid_stem}", tags=["io"])
430
  def progress(vid_stem: str):
431
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
432
-
433
- @app.delete("/delete/{vid}", tags=["io"])
434
  def delete_video(vid: str):
435
  v = DATA_DIR / vid
436
  if not v.exists():
@@ -442,8 +375,7 @@ def delete_video(vid: str):
442
  v.unlink(missing_ok=True)
443
  print(f"[DELETE] {vid}", file=sys.stdout)
444
  return {"deleted": vid}
445
-
446
- @app.get("/frame_idx", tags=["io"])
447
  def frame_idx(vid: str, idx: int):
448
  v = DATA_DIR / vid
449
  if not v.exists():
@@ -458,8 +390,7 @@ def frame_idx(vid: str, idx: int):
458
  except Exception as e:
459
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
460
  raise HTTPException(500, "Frame error")
461
-
462
- @app.get("/poster/{vid}", tags=["io"])
463
  def poster(vid: str):
464
  v = DATA_DIR / vid
465
  if not v.exists():
@@ -468,8 +399,7 @@ def poster(vid: str):
468
  if p.exists():
469
  return FileResponse(str(p), media_type="image/jpeg")
470
  raise HTTPException(404, "Poster introuvable")
471
-
472
- @app.get("/window/{vid}", tags=["io"])
473
  def window(vid: str, center: int = 0, count: int = 21):
474
  v = DATA_DIR / vid
475
  if not v.exists():
@@ -496,9 +426,8 @@ def window(vid: str, center: int = 0, count: int = 21):
496
  items.append({"i": i, "idx": idx, "url": url})
497
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
498
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
499
-
500
- # ----- Masques ---------------------------------------------------------------
501
- @app.post("/mask", tags=["mask"])
502
  async def save_mask(payload: Dict[str, Any] = Body(...)):
503
  vid = payload.get("vid")
504
  if not vid:
@@ -506,58 +435,47 @@ async def save_mask(payload: Dict[str, Any] = Body(...)):
506
  pts = payload.get("points") or []
507
  if len(pts) != 4:
508
  raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
509
- # Écriture atomique et sérialisée
510
- lock = _get_mask_lock(vid)
511
- with lock:
512
- data = _load_masks(vid)
513
- m = {
514
- "id": uuid.uuid4().hex[:10],
515
- "time_s": float(payload.get("time_s") or 0.0),
516
- "frame_idx": int(payload.get("frame_idx") or 0),
517
- "shape": "rect",
518
- "points": [float(x) for x in pts],
519
- "color": payload.get("color") or "#10b981",
520
- "note": payload.get("note") or ""
521
- }
522
- data.setdefault("masks", []).append(m)
523
- _save_masks_atomic(vid, data)
524
  print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
525
  return {"saved": True, "mask": m}
526
-
527
- @app.get("/mask/{vid}", tags=["mask"])
528
  def list_masks(vid: str):
529
  return _load_masks(vid)
530
-
531
- @app.post("/mask/rename", tags=["mask"])
532
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
533
  vid = payload.get("vid")
534
  mid = payload.get("id")
535
  new_note = (payload.get("note") or "").strip()
536
  if not vid or not mid:
537
  raise HTTPException(400, "vid et id requis")
538
- lock = _get_mask_lock(vid)
539
- with lock:
540
- data = _load_masks(vid)
541
- for m in data.get("masks", []):
542
- if m.get("id") == mid:
543
- m["note"] = new_note
544
- _save_masks_atomic(vid, data)
545
- return {"ok": True}
546
  raise HTTPException(404, "Masque introuvable")
547
-
548
- @app.post("/mask/delete", tags=["mask"])
549
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
550
  vid = payload.get("vid")
551
  mid = payload.get("id")
552
  if not vid or not mid:
553
  raise HTTPException(400, "vid et id requis")
554
- lock = _get_mask_lock(vid)
555
- with lock:
556
- data = _load_masks(vid)
557
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
558
- _save_masks_atomic(vid, data)
559
  return {"ok": True}
560
-
561
  # --- UI ----------------------------------------------------------------------
562
  HTML_TEMPLATE = r"""
563
  <!doctype html>
@@ -632,7 +550,7 @@ HTML_TEMPLATE = r"""
632
  </div>
633
  <div class="card" style="margin-bottom:10px">
634
  <div id="warmupBox">
635
- <button id="warmupBtn" class="btn">⚡ Warm-up modèles (Hub)</button>
636
  <div id="warmup-status">—</div>
637
  <div id="warmup-progress"><div id="warmup-fill"></div></div>
638
  </div>
@@ -752,9 +670,8 @@ const gotoBtn = document.getElementById('gotoBtn');
752
  // Warmup UI
753
  const warmBtn = document.getElementById('warmupBtn');
754
  const warmStat = document.getElementById('warmup-status');
755
- const warmBar = document.getElementById('warmup-fill');
756
- const warmLog = document.getElementById('warmup-log');
757
-
758
  // State
759
  let vidName = serverVid || '';
760
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -763,9 +680,8 @@ let bustToken = Date.now();
763
  let fps = 30, frames = 0;
764
  let currentIdx = 0;
765
  let mode = 'view';
766
- let rectDraft = null, dragging=false, sx=0, sy=0; // rectangle en cours (px)
767
  let color = '#10b981';
768
- // rectMap: frame_idx -> [{nx1,ny1,nx2,ny2,color}]
769
  let rectMap = new Map();
770
  let masks = [];
771
  let maskedSet = new Set();
@@ -784,7 +700,6 @@ let lastCenterMs = 0;
784
  const CENTER_THROTTLE_MS = 150;
785
  const PENDING_KEY = 've_pending_masks_v1';
786
  let maskedOnlyMode = false;
787
-
788
  // Utils
789
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
790
  function ensureOverlays(){
@@ -806,10 +721,10 @@ function updateSelectedThumb(){
806
  }
807
  function rawCenterThumb(el){
808
  const boxRect = tlBox.getBoundingClientRect();
809
- const elRect = el.getBoundingClientRect();
810
  const current = tlBox.scrollLeft;
811
- const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
812
- const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
813
  tlBox.scrollTo({ left: target, behavior: 'auto' });
814
  }
815
  async function ensureThumbVisibleCentered(idx){
@@ -874,6 +789,7 @@ async function flushPending(){
874
  savePendingList(kept);
875
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
876
  }
 
877
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
878
  function fitCanvas(){
879
  const r=player.getBoundingClientRect();
@@ -896,7 +812,7 @@ function setMode(m){
896
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
897
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
898
  canvas.style.pointerEvents='auto';
899
- rectDraft = null; draw();
900
  }else{
901
  player.controls = true;
902
  playerWrap.classList.remove('edit-mode');
@@ -904,44 +820,31 @@ function setMode(m){
904
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
905
  btnSave.style.display='none'; btnClear.style.display='none';
906
  canvas.style.pointerEvents='none';
907
- rectDraft=null; draw();
908
  }
909
  }
910
  function draw(){
911
  ctx.clearRect(0,0,canvas.width,canvas.height);
912
- // Saved rects for current frame (normalized -> px)
913
- const arr = rectMap.get(currentIdx) || [];
914
- for(const r of arr){
915
- const x1 = r.nx1 * canvas.width;
916
- const y1 = r.ny1 * canvas.height;
917
- const x2 = r.nx2 * canvas.width;
918
- const y2 = r.ny2 * canvas.height;
919
- const x=Math.min(x1,x2), y=Math.min(y1,y2);
920
- const w=Math.abs(x2-x1), h=Math.abs(y2-y1);
921
- ctx.strokeStyle=r.color||'#10b981'; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
922
- ctx.fillStyle=(r.color||'#10b981')+'28'; ctx.fillRect(x,y,w,h);
923
- }
924
- // Draft rect (px)
925
- if(rectDraft){
926
- const x=Math.min(rectDraft.x1,rectDraft.x2), y=Math.min(rectDraft.y1,rectDraft.y2);
927
- const w=Math.abs(rectDraft.x2-rectDraft.x1), h=Math.abs(rectDraft.y2-rectDraft.y1);
928
- ctx.strokeStyle=rectDraft.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
929
- ctx.fillStyle=(rectDraft.color||color)+'28'; ctx.fillRect(x,y,w,h);
930
  }
931
  }
932
  canvas.addEventListener('mousedown',(e)=>{
933
  if(mode!=='edit' || !vidName) return;
934
  dragging=true; const r=canvas.getBoundingClientRect();
935
  sx=e.clientX-r.left; sy=e.clientY-r.top;
936
- rectDraft={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
937
  });
938
  canvas.addEventListener('mousemove',(e)=>{
939
  if(!dragging) return;
940
  const r=canvas.getBoundingClientRect();
941
- rectDraft.x2=e.clientX-r.left; rectDraft.y2=e.clientY-r.top; draw();
942
  });
943
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
944
- btnClear.onclick=()=>{ rectDraft=null; draw(); };
945
  btnEdit.onclick =()=> setMode('edit');
946
  btnBack.onclick =()=> setMode('view');
947
  // Palette
@@ -950,7 +853,7 @@ palette.querySelectorAll('.swatch').forEach(el=>{
950
  el.onclick=()=>{
951
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
952
  el.classList.add('sel'); color=el.dataset.c;
953
- if(rectDraft){ rectDraft.color=color; draw(); }
954
  };
955
  });
956
  // === Timeline ===
@@ -973,15 +876,15 @@ async function renderTimeline(centerIdx){
973
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
974
  return;
975
  }
976
- if (portionStart!=null && portionEnd!=null){
977
  const s = Math.max(0, Math.min(portionStart, frames-1));
978
  const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
979
  tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
980
- for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
981
- timelineStart = s;
982
- timelineEnd = e;
983
  viewRangeStart = s;
984
- viewRangeEnd = e;
985
  setTimeout(async ()=>{
986
  syncTimelineWidth();
987
  updateSelectedThumb();
@@ -1031,8 +934,7 @@ function addThumb(idx, place='append'){
1031
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
1032
  img.onclick=async ()=>{
1033
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
1034
- rectDraft = null; // on ne change pas les rects enregistrés
1035
- draw();
1036
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
1037
  };
1038
  wrap.appendChild(img);
@@ -1065,13 +967,12 @@ tlBox.addEventListener('scroll', ()=>{
1065
  });
1066
  // Isoler & Boucle
1067
  isolerBoucle.onclick = async ()=>{
1068
- const start = Math.max(0, parseInt(goFrame.value || '1',10) - 1);
1069
- const endIn = parseInt(endPortion.value || '',10);
1070
- if(!endPortion.value || endIn <= (start+1) || endIn > frames){ alert('Portion invalide (fin > début)'); return; }
1071
- const endExclusive = Math.min(frames, endIn); // fin exclusive; "55" => inclut #55 (idx 54)
1072
- portionStart = start;
1073
- portionEnd = endExclusive;
1074
- viewRangeStart = start; viewRangeEnd = endExclusive;
1075
  player.pause(); isPaused = true;
1076
  currentIdx = start; player.currentTime = idxToSec(start);
1077
  await renderTimeline(currentIdx);
@@ -1109,7 +1010,7 @@ function attachHandleDrag(handle, which){
1109
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1110
  }
1111
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1112
- // Progress popup (thumbs)
1113
  async function showProgress(vidStem){
1114
  popup.style.display = 'block';
1115
  const interval = setInterval(async () => {
@@ -1117,7 +1018,7 @@ async function showProgress(vidStem){
1117
  const d = await r.json();
1118
  tlProgressFill.style.width = d.percent + '%';
1119
  popupProgressFill.style.width = d.percent + '%';
1120
- popupLogs.innerHTML = (d.logs||[]).map(x=>String(x)).join('<br>');
1121
  if(d.done){
1122
  clearInterval(interval);
1123
  popup.style.display = 'none';
@@ -1159,28 +1060,27 @@ player.addEventListener('loadedmetadata', async ()=>{
1159
  if(!frames || frames<=0){
1160
  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{}
1161
  }
1162
- currentIdx=0; goFrame.value=1; rectMap.clear(); rectDraft=null; draw();
1163
  });
1164
- window.addEventListener('resize', ()=>{ fitCanvas(); draw(); });
1165
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1166
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1167
  player.addEventListener('timeupdate', ()=>{
1168
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1169
  currentIdx=timeToIdx(player.currentTime);
1170
- draw();
1171
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1172
  if(followMode && !isPaused){
1173
  const now = Date.now();
1174
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1175
  }
1176
  });
1177
-
1178
  goFrame.addEventListener('change', async ()=>{
1179
  if(!vidName) return;
1180
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1181
  player.pause(); isPaused = true;
1182
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1183
- rectDraft = null; draw();
1184
  await renderTimeline(currentIdx);
1185
  await ensureThumbVisibleCentered(currentIdx);
1186
  });
@@ -1200,7 +1100,6 @@ async function gotoFrameNum(){
1200
  player.pause(); isPaused = true;
1201
  currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1202
  goFrame.value = v;
1203
- rectDraft = null; draw();
1204
  await renderTimeline(currentIdx);
1205
  await ensureThumbVisibleCentered(currentIdx);
1206
  }
@@ -1245,17 +1144,15 @@ async function loadMasks(){
1245
  masks=d.masks||[];
1246
  maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1247
  rectMap.clear();
1248
- // MULTI-RECT: on stocke en coordonnées normalisées; plusieurs par frame
1249
  masks.forEach(m=>{
1250
  if(m.shape==='rect'){
1251
- const [nx1,ny1,nx2,ny2] = m.points;
1252
- const arr = rectMap.get(m.frame_idx) || [];
1253
- arr.push({nx1,ny1,nx2,ny2,color:m.color});
1254
- rectMap.set(m.frame_idx, arr);
1255
  }
1256
  });
1257
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1258
- if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; draw(); return; }
1259
  box.innerHTML='';
1260
  const ul=document.createElement('ul'); ul.className='clean';
1261
  masks.forEach(m=>{
@@ -1286,29 +1183,25 @@ async function loadMasks(){
1286
  });
1287
  box.appendChild(ul);
1288
  loadingInd.style.display='none';
1289
- draw();
1290
  }
1291
- // Save mask (+ cache) — MULTI-RECT
1292
  btnSave.onclick = async ()=>{
1293
- if(!rectDraft || !vidName){ alert('Aucune sélection.'); return; }
1294
  const defaultName = `frame ${currentIdx+1}`;
1295
  const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1296
- const nx1 = Math.min(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1297
- const ny1 = Math.min(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1298
- const nx2 = Math.max(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1299
- const ny2 = Math.max(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1300
- const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[nx1,ny1,nx2,ny2],color:rectDraft.color||color,note:note};
 
1301
  addPending(payload);
1302
  try{
1303
  const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1304
  if(r.ok){
1305
  const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1306
  savePendingList(lst);
1307
- // pousse dans rectMap (ne supprime pas les anciens)
1308
- const arr = rectMap.get(currentIdx) || [];
1309
- arr.push({nx1,ny1,nx2,ny2,color:payload.color});
1310
- rectMap.set(currentIdx, arr);
1311
- rectDraft = null;
1312
  await loadMasks(); await renderTimeline(currentIdx);
1313
  showToast('Masque enregistré ✅');
1314
  } else {
@@ -1329,7 +1222,7 @@ warmBtn.onclick = async ()=>{
1329
  warmBar.style.width = (st.percent||0) + '%';
1330
  let txt = (st.state||'idle');
1331
  if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1332
- if(st.state==='error'){ txt += ' · erreur'; }
1333
  warmStat.textContent = txt;
1334
  const lines = (st.log||[]).slice(-6);
1335
  warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
@@ -1353,8 +1246,7 @@ document.head.appendChild(style);
1353
  </script>
1354
  </html>
1355
  """
1356
-
1357
- @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1358
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1359
  vid = v or ""
1360
  try:
@@ -1362,8 +1254,4 @@ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1362
  except Exception:
1363
  pass
1364
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1365
- return HTMLResponse(content=html)
1366
- '''
1367
- out_path = Path("/mnt/data/app_v0_8_2_multi_rect_atomic.py")
1368
- out_path.write_text(code, encoding="utf-8")
1369
- print(f"Wrote: {out_path} ({out_path.stat().st_size} bytes)")
 
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
  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
  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
  "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
  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
  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
  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
  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:
 
213
  proc.wait()
214
  generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
215
  progress_data[vid_stem]['percent'] = 100
216
+ progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
217
  progress_data[vid_stem]['done'] = True
218
  print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
219
  else:
 
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
  "finished_at": None,
264
  "last_error": "",
265
  }
 
266
  WARMUP_MODELS: List[str] = [
267
  "facebook/sam2-hiera-large",
268
  "lixiaowen/diffuEraser",
 
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
  _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
  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
  "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
337
  if not v.exists():
 
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()
346
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
 
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
369
  if not v.exists():
 
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
381
  if not v.exists():
 
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
396
  if not v.exists():
 
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
405
  if not v.exists():
 
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")
433
  if not vid:
 
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")
458
  mid = payload.get("id")
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>
 
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
  // 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
  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
  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
  }
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
  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
  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
  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
  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
  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
  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);
 
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);
 
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
  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
  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
  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
  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=>{
 
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 {
 
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
  </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 ""
1252
  try:
 
1254
  except Exception:
1255
  pass
1256
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1257
+ return HTMLResponse(content=html)