FABLESLIP commited on
Commit
8c2ad4f
·
verified ·
1 Parent(s): b009c25

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +75 -189
app.py CHANGED
@@ -1,7 +1,8 @@
1
- ...donne des parties à corriger, mais moi je veux le script complet et corrigé, sans rien casser.
2
-
3
- # app.py Video Editor API (v0.8.0)
4
- # v0.8.0: Chargement modèles Hub, stubs IA, + Améliorations : multi-masques, estimation /estimate, progression /progress_ia
 
5
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
6
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
7
  from fastapi.staticfiles import StaticFiles
@@ -47,7 +48,7 @@ def get_backend_base() -> str:
47
  print("[BOOT] Video Editor API starting…")
48
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
49
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
50
- app = FastAPI(title="Video Editor API", version="0.8.0")
51
  DATA_DIR = Path("/app/data")
52
  THUMB_DIR = DATA_DIR / "_thumbs"
53
  MASK_DIR = DATA_DIR / "_masks"
@@ -257,123 +258,24 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
257
  except Exception as e:
258
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
259
  progress_data[vid_stem]['done'] = True
260
- # --- Chargement Modèles au Boot ---
261
- import huggingface_hub as hf
262
- from joblib import Parallel, delayed
263
- def load_model(repo_id):
264
- path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
265
- if not path.exists() or not any(path.iterdir()):
266
- print(f"[BOOT] Downloading {repo_id}...")
267
- hf.snapshot_download(repo_id=repo_id, local_dir=str(path), token=os.getenv("HF_TOKEN"))
268
- (path / "loaded.ok").touch()
269
- # Symlink exemples
270
- if "sam2" in repo_id: shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
271
- models = [
272
- "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
273
- "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
274
- ]
275
- print("[BOOT] Loading models from Hub...")
276
- Parallel(n_jobs=4)(delayed(load_model)(m) for m in models)
277
- # ProPainter wget
278
- PROP = Path("/app/propainter")
279
- PROP.mkdir(exist_ok=True)
280
- def wget(url, dest):
281
- if not (dest / url.split("/")[-1]).exists():
282
- subprocess.run(["wget", "-q", url, "-P", str(dest)])
283
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth", PROP)
284
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth", PROP)
285
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth", PROP)
286
- print("[BOOT] Models ready.")
287
- # --- Nouveaux Helpers pour IA/GPU ---
288
- def is_gpu():
289
- import torch
290
- return torch.cuda.is_available()
291
- # --- Nouveaux Endpoints pour IA et Améliorations ---
292
- @app.post("/mask/ai")
293
- async def mask_ai(payload: Dict[str, Any] = Body(...)):
294
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
295
- # TODO: Impl SAM2
296
- return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
297
- @app.post("/inpaint")
298
- async def inpaint(payload: Dict[str, Any] = Body(...)):
299
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
300
- # TODO: Impl DiffuEraser, update progress_ia
301
- return {"ok": True, "preview": "/data/preview.mp4"}
302
- @app.get("/estimate")
303
- def estimate(vid: str, masks_count: int):
304
- # TODO: Calcul (frames * masks * facteur)
305
- return {"time_min": 5, "vram_gb": 4}
306
- @app.get("/progress_ia")
307
- def progress_ia(vid: str):
308
- # TODO: % et logs frame/frame
309
- return {"percent": 0, "log": "En cours..."}
310
- # --- Routes existantes (étendues pour multi-masques) ---
311
- @app.post("/mask")
312
- async def save_mask(payload: Dict[str, Any] = Body(...)):
313
- vid = payload.get("vid")
314
- if not vid:
315
- raise HTTPException(400, "vid manquant")
316
- pts = payload.get("points") or []
317
- if len(pts) != 4:
318
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
319
- data = _load_masks(vid)
320
- m = {
321
- "id": uuid.uuid4().hex[:10],
322
- "time_s": float(payload.get("time_s") or 0.0),
323
- "frame_idx": int(payload.get("frame_idx") or 0),
324
- "shape": "rect",
325
- "points": [float(x) for x in pts],
326
- "color": payload.get("color") or "#10b981",
327
- "note": payload.get("note") or ""
328
- }
329
- data.setdefault("masks", []).append(m)
330
- _save_masks(vid, data)
331
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
332
- return {"saved": True, "mask": m}
333
- @app.get("/mask/{vid}")
334
- def list_masks(vid: str):
335
- return _load_masks(vid)
336
- @app.post("/mask/rename")
337
- async def rename_mask(payload: Dict[str, Any] = Body(...)):
338
- vid = payload.get("vid")
339
- mid = payload.get("id")
340
- new_note = (payload.get("note") or "").strip()
341
- if not vid or not mid:
342
- raise HTTPException(400, "vid et id requis")
343
- data = _load_masks(vid)
344
- for m in data.get("masks", []):
345
- if m.get("id") == mid:
346
- m["note"] = new_note
347
- _save_masks(vid, data)
348
- return {"ok": True}
349
- raise HTTPException(404, "Masque introuvable")
350
- @app.post("/mask/delete")
351
- async def delete_mask(payload: Dict[str, Any] = Body(...)):
352
- vid = payload.get("vid")
353
- mid = payload.get("id")
354
- if not vid or not mid:
355
- raise HTTPException(400, "vid et id requis")
356
- data = _load_masks(vid)
357
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
358
- _save_masks(vid, data)
359
- return {"ok": True}
360
- @app.get("/")
361
  def root():
362
  return {
363
  "ok": True,
364
  "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
365
  }
366
- @app.get("/health")
367
  def health():
368
  return {"status": "ok"}
369
- @app.get("/_env")
370
  def env_info():
371
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
372
- @app.get("/files")
373
  def files():
374
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
375
  return {"count": len(items), "items": items}
376
- @app.get("/meta/{vid}")
377
  def video_meta(vid: str):
378
  v = DATA_DIR / vid
379
  if not v.exists():
@@ -382,7 +284,7 @@ def video_meta(vid: str):
382
  if not m:
383
  raise HTTPException(500, "Métadonnées indisponibles")
384
  return m
385
- @app.post("/upload")
386
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
387
  ext = (Path(file.filename).suffix or ".mp4").lower()
388
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
@@ -402,10 +304,10 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
402
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
403
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
404
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
405
- @app.get("/progress/{vid_stem}")
406
  def progress(vid_stem: str):
407
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
408
- @app.delete("/delete/{vid}")
409
  def delete_video(vid: str):
410
  v = DATA_DIR / vid
411
  if not v.exists():
@@ -417,7 +319,7 @@ def delete_video(vid: str):
417
  v.unlink(missing_ok=True)
418
  print(f"[DELETE] {vid}", file=sys.stdout)
419
  return {"deleted": vid}
420
- @app.get("/frame_idx")
421
  def frame_idx(vid: str, idx: int):
422
  v = DATA_DIR / vid
423
  if not v.exists():
@@ -432,7 +334,7 @@ def frame_idx(vid: str, idx: int):
432
  except Exception as e:
433
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
434
  raise HTTPException(500, "Frame error")
435
- @app.get("/poster/{vid}")
436
  def poster(vid: str):
437
  v = DATA_DIR / vid
438
  if not v.exists():
@@ -441,7 +343,7 @@ def poster(vid: str):
441
  if p.exists():
442
  return FileResponse(str(p), media_type="image/jpeg")
443
  raise HTTPException(404, "Poster introuvable")
444
- @app.get("/window/{vid}")
445
  def window(vid: str, center: int = 0, count: int = 21):
446
  v = DATA_DIR / vid
447
  if not v.exists():
@@ -468,7 +370,57 @@ def window(vid: str, center: int = 0, count: int = 21):
468
  items.append({"i": i, "idx": idx, "url": url})
469
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
470
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
471
- # ---------- UI (étendue pour améliorations) ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  HTML_TEMPLATE = r"""
473
  <!doctype html>
474
  <html lang="fr"><meta charset="utf-8">
@@ -567,17 +519,11 @@ HTML_TEMPLATE = r"""
567
  <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
568
  <button id="gotoBtn" class="btn">Aller</button>
569
  <span class="muted" id="maskedCount"></span>
570
- <!-- Nouveaux boutons undo/redo (près des contrôles) -->
571
- <button id="btnUndo">↩️ Undo</button>
572
- <button id="btnRedo">↪️ Redo</button>
573
  </div>
574
  <div id="timeline" class="timeline"></div>
575
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
576
  <div id="loading-indicator">Chargement des frames...</div>
577
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
578
- <!-- Nouvelle barre de progression IA (sous timeline) -->
579
- <div id="progressBar"><div id="progressFill"></div></div>
580
- <div>Progression IA: <span id="progressLog">En attente...</span></div>
581
  </div>
582
  </div>
583
  <div class="card tools">
@@ -587,8 +533,6 @@ HTML_TEMPLATE = r"""
587
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
588
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
589
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
590
- <!-- Nouveau bouton preview IA (près de save) -->
591
- <button id="btnPreview">🔍 Preview IA</button>
592
  </div>
593
  <div style="margin-top:10px">
594
  <div class="muted">Couleur</div>
@@ -619,8 +563,6 @@ HTML_TEMPLATE = r"""
619
  <div id="popup-logs"></div>
620
  </div>
621
  <div id="toast"></div>
622
- <!-- Nouveau : Tutoriel masquable (au bas, optionnel) -->
623
- <div id="tutorial" onclick="this.classList.add('hidden')">Tutoriel (cliquer pour masquer)<br>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</div>
624
  <script>
625
  const serverVid = "__VID__";
626
  const serverMsg = "__MSG__";
@@ -659,12 +601,6 @@ const hud = document.getElementById('hud');
659
  const toastWrap = document.getElementById('toast');
660
  const gotoInput = document.getElementById('gotoInput');
661
  const gotoBtn = document.getElementById('gotoBtn');
662
- // Nouveaux éléments pour améliorations
663
- const btnUndo = document.getElementById('btnUndo');
664
- const btnRedo = document.getElementById('btnRedo');
665
- const btnPreview = document.getElementById('btnPreview');
666
- const progressFill = document.getElementById('progressFill');
667
- const progressLog = document.getElementById('progressLog');
668
  // State
669
  let vidName = serverVid || '';
670
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -693,9 +629,6 @@ let lastCenterMs = 0;
693
  const CENTER_THROTTLE_MS = 150;
694
  const PENDING_KEY = 've_pending_masks_v1';
695
  let maskedOnlyMode = false;
696
- // Nouveaux pour undo/redo
697
- let history = [];
698
- let redoStack = [];
699
  // Utils
700
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
701
  function ensureOverlays(){
@@ -1010,7 +943,7 @@ async function loadVideoAndMeta() {
1010
  vidStem = fileStem(vidName); bustToken = Date.now();
1011
  const bust = Date.now();
1012
  srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1013
- player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust;
1014
  player.load();
1015
  fitCanvas();
1016
  statusEl.textContent = 'Chargement vidéo…';
@@ -1105,7 +1038,7 @@ async function loadFiles(){
1105
  d.items.forEach(name=>{
1106
  const li=document.createElement('li');
1107
  const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1108
- delBtn.onclick=async()=>{
1109
  if(!confirm(`Supprimer "${name}" ?`)) return;
1110
  await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1111
  loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
@@ -1141,7 +1074,7 @@ async function loadMasks(){
1141
  const label=m.note||(`frame ${fr}`);
1142
  li.innerHTML = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${col};margin-right:6px;vertical-align:middle"></span> <strong>${label.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</strong> — #${fr} · t=${t}s`;
1143
  const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1144
- renameBtn.onclick=async()=>{
1145
  const nv=prompt('Nouveau nom du masque :', label);
1146
  if(nv===null) return;
1147
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
@@ -1150,7 +1083,7 @@ async function loadMasks(){
1150
  }
1151
  };
1152
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1153
- delMaskBtn.onclick=async()=>{
1154
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1155
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1156
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
@@ -1190,59 +1123,12 @@ btnSave.onclick = async ()=>{
1190
  alert('Erreur réseau lors de l’enregistrement du masque.');
1191
  }
1192
  };
1193
- // Nouveaux : Undo/Redo (pour rects)
1194
- btnUndo.onclick = () => {
1195
- if (history.length) {
1196
- const last = history.pop();
1197
- redoStack.push({...rect});
1198
- rect = last;
1199
- draw();
1200
- }
1201
- };
1202
- btnRedo.onclick = () => {
1203
- if (redoStack.length) {
1204
- const next = redoStack.pop();
1205
- history.push({...rect});
1206
- rect = next;
1207
- draw();
1208
- }
1209
- };
1210
- canvas.addEventListener('mouseup', () => {
1211
- if (dragging) { history.push({...rect}); redoStack = []; }
1212
- });
1213
- // Nouveau : Preview IA (stub)
1214
- btnPreview.onclick = async () => {
1215
- if (!vidName) return;
1216
- const payload = {vid: vidName}; // TODO: Ajouter masques sélectionnés
1217
- const r = await fetch('/inpaint', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
1218
- if (r.ok) {
1219
- const d = await r.json();
1220
- alert('Preview prête : ' + d.preview); // TODO: Popup avec <video> preview
1221
- } else {
1222
- alert('Échec preview IA');
1223
- }
1224
- };
1225
- // Nouveau : Feedback progression IA (poll every 2s)
1226
- function updateProgress() {
1227
- if (!vidName) return;
1228
- fetch(`/progress_ia?vid=${encodeURIComponent(vidName)}`).then(r => r.json()).then(d => {
1229
- progressFill.style.width = `${d.percent}%`;
1230
- progressLog.textContent = d.log;
1231
- setTimeout(updateProgress, 2000);
1232
- });
1233
- }
1234
- updateProgress();
1235
- // Nouveau : Auto-save enhanced (récup au boot si crash)
1236
  async function boot(){
1237
- const lst = loadPending(); if (lst.length) { showToast(`Récupération de ${lst.length} masques pending`); await flushPending(); }
1238
  await loadFiles();
1239
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1240
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1241
- // Tutoriel : Show if first time
1242
- if (!localStorage.getItem('tutorialSeen')) {
1243
- document.getElementById('tutorial').style.display = 'block';
1244
- localStorage.setItem('tutorialSeen', '1');
1245
- }
1246
  }
1247
  boot();
1248
  // Hide controls in edit-mode
 
1
+ # app.py Video Editor API (v0.5.9)
2
+ # v0.5.9:
3
+ # - Centrages "Aller à #" / "Frame #" 100% fiables (attend rendu + image chargée)
4
+ # - /mask, /mask/rename, /mask/delete : Body(...) explicite => plus de 422 silencieux
5
+ # - Bouton "Enregistrer masque" : erreurs visibles (alert) si l’API ne répond pas OK
6
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
7
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
8
  from fastapi.staticfiles import StaticFiles
 
48
  print("[BOOT] Video Editor API starting…")
49
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
50
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
51
+ app = FastAPI(title="Video Editor API", version="0.5.9")
52
  DATA_DIR = Path("/app/data")
53
  THUMB_DIR = DATA_DIR / "_thumbs"
54
  MASK_DIR = DATA_DIR / "_masks"
 
258
  except Exception as e:
259
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
260
  progress_data[vid_stem]['done'] = True
261
+ # ---------- API ----------
262
+ @app.get("/", tags=["meta"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  def root():
264
  return {
265
  "ok": True,
266
  "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
267
  }
268
+ @app.get("/health", tags=["meta"])
269
  def health():
270
  return {"status": "ok"}
271
+ @app.get("/_env", tags=["meta"])
272
  def env_info():
273
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
274
+ @app.get("/files", tags=["io"])
275
  def files():
276
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
277
  return {"count": len(items), "items": items}
278
+ @app.get("/meta/{vid}", tags=["io"])
279
  def video_meta(vid: str):
280
  v = DATA_DIR / vid
281
  if not v.exists():
 
284
  if not m:
285
  raise HTTPException(500, "Métadonnées indisponibles")
286
  return m
287
+ @app.post("/upload", tags=["io"])
288
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
289
  ext = (Path(file.filename).suffix or ".mp4").lower()
290
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
 
304
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
305
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
306
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
307
+ @app.get("/progress/{vid_stem}", tags=["io"])
308
  def progress(vid_stem: str):
309
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
310
+ @app.delete("/delete/{vid}", tags=["io"])
311
  def delete_video(vid: str):
312
  v = DATA_DIR / vid
313
  if not v.exists():
 
319
  v.unlink(missing_ok=True)
320
  print(f"[DELETE] {vid}", file=sys.stdout)
321
  return {"deleted": vid}
322
+ @app.get("/frame_idx", tags=["io"])
323
  def frame_idx(vid: str, idx: int):
324
  v = DATA_DIR / vid
325
  if not v.exists():
 
334
  except Exception as e:
335
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
336
  raise HTTPException(500, "Frame error")
337
+ @app.get("/poster/{vid}", tags=["io"])
338
  def poster(vid: str):
339
  v = DATA_DIR / vid
340
  if not v.exists():
 
343
  if p.exists():
344
  return FileResponse(str(p), media_type="image/jpeg")
345
  raise HTTPException(404, "Poster introuvable")
346
+ @app.get("/window/{vid}", tags=["io"])
347
  def window(vid: str, center: int = 0, count: int = 21):
348
  v = DATA_DIR / vid
349
  if not v.exists():
 
370
  items.append({"i": i, "idx": idx, "url": url})
371
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
372
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
373
+ # ----- Masques -----
374
+ @app.post("/mask", tags=["mask"])
375
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
376
+ vid = payload.get("vid")
377
+ if not vid:
378
+ raise HTTPException(400, "vid manquant")
379
+ pts = payload.get("points") or []
380
+ if len(pts) != 4:
381
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
382
+ data = _load_masks(vid)
383
+ m = {
384
+ "id": uuid.uuid4().hex[:10],
385
+ "time_s": float(payload.get("time_s") or 0.0),
386
+ "frame_idx": int(payload.get("frame_idx") or 0),
387
+ "shape": "rect",
388
+ "points": [float(x) for x in pts],
389
+ "color": payload.get("color") or "#10b981",
390
+ "note": payload.get("note") or ""
391
+ }
392
+ data.setdefault("masks", []).append(m)
393
+ _save_masks(vid, data)
394
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
395
+ return {"saved": True, "mask": m}
396
+ @app.get("/mask/{vid}", tags=["mask"])
397
+ def list_masks(vid: str):
398
+ return _load_masks(vid)
399
+ @app.post("/mask/rename", tags=["mask"])
400
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
401
+ vid = payload.get("vid")
402
+ mid = payload.get("id")
403
+ new_note = (payload.get("note") or "").strip()
404
+ if not vid or not mid:
405
+ raise HTTPException(400, "vid et id requis")
406
+ data = _load_masks(vid)
407
+ for m in data.get("masks", []):
408
+ if m.get("id") == mid:
409
+ m["note"] = new_note
410
+ _save_masks(vid, data)
411
+ return {"ok": True}
412
+ raise HTTPException(404, "Masque introuvable")
413
+ @app.post("/mask/delete", tags=["mask"])
414
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
415
+ vid = payload.get("vid")
416
+ mid = payload.get("id")
417
+ if not vid or not mid:
418
+ raise HTTPException(400, "vid et id requis")
419
+ data = _load_masks(vid)
420
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
421
+ _save_masks(vid, data)
422
+ return {"ok": True}
423
+ # ---------- UI ----------
424
  HTML_TEMPLATE = r"""
425
  <!doctype html>
426
  <html lang="fr"><meta charset="utf-8">
 
519
  <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
520
  <button id="gotoBtn" class="btn">Aller</button>
521
  <span class="muted" id="maskedCount"></span>
 
 
 
522
  </div>
523
  <div id="timeline" class="timeline"></div>
524
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
525
  <div id="loading-indicator">Chargement des frames...</div>
526
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
 
 
 
527
  </div>
528
  </div>
529
  <div class="card tools">
 
533
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
534
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
535
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
 
 
536
  </div>
537
  <div style="margin-top:10px">
538
  <div class="muted">Couleur</div>
 
563
  <div id="popup-logs"></div>
564
  </div>
565
  <div id="toast"></div>
 
 
566
  <script>
567
  const serverVid = "__VID__";
568
  const serverMsg = "__MSG__";
 
601
  const toastWrap = document.getElementById('toast');
602
  const gotoInput = document.getElementById('gotoInput');
603
  const gotoBtn = document.getElementById('gotoBtn');
 
 
 
 
 
 
604
  // State
605
  let vidName = serverVid || '';
606
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
 
629
  const CENTER_THROTTLE_MS = 150;
630
  const PENDING_KEY = 've_pending_masks_v1';
631
  let maskedOnlyMode = false;
 
 
 
632
  // Utils
633
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
634
  function ensureOverlays(){
 
943
  vidStem = fileStem(vidName); bustToken = Date.now();
944
  const bust = Date.now();
945
  srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
946
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
947
  player.load();
948
  fitCanvas();
949
  statusEl.textContent = 'Chargement vidéo…';
 
1038
  d.items.forEach(name=>{
1039
  const li=document.createElement('li');
1040
  const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1041
+ delBtn.onclick=async=>{
1042
  if(!confirm(`Supprimer "${name}" ?`)) return;
1043
  await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1044
  loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
 
1074
  const label=m.note||(`frame ${fr}`);
1075
  li.innerHTML = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${col};margin-right:6px;vertical-align:middle"></span> <strong>${label.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</strong> — #${fr} · t=${t}s`;
1076
  const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1077
+ renameBtn.onclick=async=>{
1078
  const nv=prompt('Nouveau nom du masque :', label);
1079
  if(nv===null) return;
1080
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
 
1083
  }
1084
  };
1085
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1086
+ delMaskBtn.onclick=async=>{
1087
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1088
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1089
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
 
1123
  alert('Erreur réseau lors de l’enregistrement du masque.');
1124
  }
1125
  };
1126
+ // Boot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  async function boot(){
1128
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1129
  await loadFiles();
1130
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1131
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
 
 
 
 
 
1132
  }
1133
  boot();
1134
  // Hide controls in edit-mode