FABLESLIP commited on
Commit
b009c25
·
verified ·
1 Parent(s): 0c524f7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +162 -166
app.py CHANGED
@@ -1,8 +1,7 @@
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,7 +47,7 @@ def get_backend_base() -> str:
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,121 +257,123 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
258
  except Exception as e:
259
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
260
  progress_data[vid_stem]['done'] = True
261
- # --- Nouveaux Helpers pour IA/GPU (ajoutés sans chargement au boot) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  def is_gpu():
263
  import torch
264
  return torch.cuda.is_available()
265
- # --- Warm-up séquentiel (ajout pour préparer les modèles le soir) ---
266
- import huggingface_hub as hf
267
- HF_HOME = Path(os.getenv("HF_HOME", "/home/user/.cache/huggingface")).resolve()
268
- HF_HOME.mkdir(parents=True, exist_ok=True)
269
- MODELS_TO_PREP = [
270
- "facebook/sam2-hiera-large",
271
- "ByteDance/Sa2VA-4B",
272
- "lixiaowen/diffuEraser",
273
- "runwayml/stable-diffusion-v1-5",
274
- "wangfuyun/PCM_Weights",
275
- "stabilityai/sd-vae-ft-mse"
276
- ]
277
- WARMUP_STATUS = {
278
- "running": False,
279
- "done": False,
280
- "step": None,
281
- "ok": [],
282
- "errors": [],
283
- "progress": 0,
284
- }
285
- def _snapshot_once(repo_id):
286
- target = HF_HOME / repo_id.split("/")[-1]
287
- try:
288
- print(f"[WARMUP] Prepare {repo_id} → {target}")
289
- hf.snapshot_download(repo_id=repo_id, local_dir=str(target), token=os.getenv("HF_TOKEN"))
290
- return True, None
291
- except Exception as e:
292
- return False, str(e)
293
- # ProPainter
294
- PROP = Path("/app/propainter"); PROP.mkdir(exist_ok=True)
295
- PROP_FILES = [
296
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth",
297
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth",
298
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth",
299
- ]
300
- def _ensure_propainter():
301
- for url in PROP_FILES:
302
- fname = url.split("/")[-1]
303
- if not (PROP / fname).exists():
304
- print(f"[WARMUP] wget {fname}")
305
- subprocess.run(["wget", "-q", url, "-P", str(PROP)], check=False)
306
- def _run_warmup():
307
- WARMUP_STATUS.update({"running": True, "done": False, "step": None, "ok": [], "errors": [], "progress": 0})
308
- total = len(MODELS_TO_PREP) + 1
309
- step_idx = 0
310
- for repo in MODELS_TO_PREP:
311
- WARMUP_STATUS["step"] = repo
312
- step_idx += 1
313
- for attempt in range(1, 4):
314
- ok, err = _snapshot_once(repo)
315
- if ok:
316
- WARMUP_STATUS["ok"].append(repo)
317
- break
318
- else:
319
- WARMUP_STATUS["errors"].append({"repo": repo, "attempt": attempt, "error": err})
320
- time.sleep(5)
321
- WARMUP_STATUS["progress"] = int(step_idx * 100 / total)
322
- WARMUP_STATUS["step"] = "ProPainter"
323
- step_idx += 1
324
- _ensure_propainter()
325
- WARMUP_STATUS["progress"] = 100
326
- WARMUP_STATUS.update({"running": False, "done": True})
327
- # --- Nouveaux Endpoints pour IA stubs (lazy, no load yet) ---
328
  @app.post("/mask/ai")
329
  async def mask_ai(payload: Dict[str, Any] = Body(...)):
330
- if not is_gpu(): raise HTTPException(503, "Switch to GPU for IA.")
331
- # TODO: Lazy load SAM2 here when impl
332
  return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
333
  @app.post("/inpaint")
334
  async def inpaint(payload: Dict[str, Any] = Body(...)):
335
- if not is_gpu(): raise HTTPException(503, "Switch to GPU for IA.")
336
- # TODO: Lazy load DiffuEraser here
337
  return {"ok": True, "preview": "/data/preview.mp4"}
338
  @app.get("/estimate")
339
  def estimate(vid: str, masks_count: int):
340
- # TODO: Lazy load if needed
341
- time_min = 5 if is_gpu() else 30
342
- vram_gb = 4 if is_gpu() else 0
343
- return {"time_min": time_min, "vram_gb": vram_gb}
344
  @app.get("/progress_ia")
345
  def progress_ia(vid: str):
346
- # TODO: Real progress
347
  return {"percent": 0, "log": "En cours..."}
348
- # --- Routes warm-up ---
349
- @app.post("/warmup")
350
- def trigger_warmup():
351
- if WARMUP_STATUS["running"]:
352
- return {"started": False, "message": "Déjà en cours"}
353
- threading.Thread(target=_run_warmup, daemon=True).start()
354
- return {"started": True}
355
- @app.get("/warmup_status")
356
- def warmup_status():
357
- return WARMUP_STATUS
358
- # ---------- API (original, étendu pour multi ok) ----------
359
- @app.get("/", tags=["meta"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  def root():
361
  return {
362
  "ok": True,
363
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup", "/warmup_status", "/mask/ai", "/inpaint", "/estimate", "/progress_ia"]
364
  }
365
- @app.get("/health", tags=["meta"])
366
  def health():
367
  return {"status": "ok"}
368
- @app.get("/_env", tags=["meta"])
369
  def env_info():
370
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
371
- @app.get("/files", tags=["io"])
372
  def files():
373
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
374
  return {"count": len(items), "items": items}
375
- @app.get("/meta/{vid}", tags=["io"])
376
  def video_meta(vid: str):
377
  v = DATA_DIR / vid
378
  if not v.exists():
@@ -381,7 +382,7 @@ def video_meta(vid: str):
381
  if not m:
382
  raise HTTPException(500, "Métadonnées indisponibles")
383
  return m
384
- @app.post("/upload", tags=["io"])
385
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
386
  ext = (Path(file.filename).suffix or ".mp4").lower()
387
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
@@ -401,10 +402,10 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
401
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
402
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
403
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
404
- @app.get("/progress/{vid_stem}", tags=["io"])
405
  def progress(vid_stem: str):
406
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
407
- @app.delete("/delete/{vid}", tags=["io"])
408
  def delete_video(vid: str):
409
  v = DATA_DIR / vid
410
  if not v.exists():
@@ -416,7 +417,7 @@ def delete_video(vid: str):
416
  v.unlink(missing_ok=True)
417
  print(f"[DELETE] {vid}", file=sys.stdout)
418
  return {"deleted": vid}
419
- @app.get("/frame_idx", tags=["io"])
420
  def frame_idx(vid: str, idx: int):
421
  v = DATA_DIR / vid
422
  if not v.exists():
@@ -431,7 +432,7 @@ def frame_idx(vid: str, idx: int):
431
  except Exception as e:
432
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
433
  raise HTTPException(500, "Frame error")
434
- @app.get("/poster/{vid}", tags=["io"])
435
  def poster(vid: str):
436
  v = DATA_DIR / vid
437
  if not v.exists():
@@ -440,7 +441,7 @@ def poster(vid: str):
440
  if p.exists():
441
  return FileResponse(str(p), media_type="image/jpeg")
442
  raise HTTPException(404, "Poster introuvable")
443
- @app.get("/window/{vid}", tags=["io"])
444
  def window(vid: str, center: int = 0, count: int = 21):
445
  v = DATA_DIR / vid
446
  if not v.exists():
@@ -467,57 +468,7 @@ def window(vid: str, center: int = 0, count: int = 21):
467
  items.append({"i": i, "idx": idx, "url": url})
468
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
469
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
470
- # ----- Masques -----
471
- @app.post("/mask", tags=["mask"])
472
- async def save_mask(payload: Dict[str, Any] = Body(...)):
473
- vid = payload.get("vid")
474
- if not vid:
475
- raise HTTPException(400, "vid manquant")
476
- pts = payload.get("points") or []
477
- if len(pts) != 4:
478
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
479
- data = _load_masks(vid)
480
- m = {
481
- "id": uuid.uuid4().hex[:10],
482
- "time_s": float(payload.get("time_s") or 0.0),
483
- "frame_idx": int(payload.get("frame_idx") or 0),
484
- "shape": "rect",
485
- "points": [float(x) for x in pts],
486
- "color": payload.get("color") or "#10b981",
487
- "note": payload.get("note") or ""
488
- }
489
- data.setdefault("masks", []).append(m)
490
- _save_masks(vid, data)
491
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
492
- return {"saved": True, "mask": m}
493
- @app.get("/mask/{vid}", tags=["mask"])
494
- def list_masks(vid: str):
495
- return _load_masks(vid)
496
- @app.post("/mask/rename", tags=["mask"])
497
- async def rename_mask(payload: Dict[str, Any] = Body(...)):
498
- vid = payload.get("vid")
499
- mid = payload.get("id")
500
- new_note = (payload.get("note") or "").strip()
501
- if not vid or not mid:
502
- raise HTTPException(400, "vid et id requis")
503
- data = _load_masks(vid)
504
- for m in data.get("masks", []):
505
- if m.get("id") == mid:
506
- m["note"] = new_note
507
- _save_masks(vid, data)
508
- return {"ok": True}
509
- raise HTTPException(404, "Masque introuvable")
510
- @app.post("/mask/delete", tags=["mask"])
511
- async def delete_mask(payload: Dict[str, Any] = Body(...)):
512
- vid = payload.get("vid")
513
- mid = payload.get("id")
514
- if not vid or not mid:
515
- raise HTTPException(400, "vid et id requis")
516
- data = _load_masks(vid)
517
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
518
- _save_masks(vid, data)
519
- return {"ok": True}
520
- # ---------- UI ----------
521
  HTML_TEMPLATE = r"""
522
  <!doctype html>
523
  <html lang="fr"><meta charset="utf-8">
@@ -585,7 +536,6 @@ HTML_TEMPLATE = r"""
585
  <input type="file" name="file" accept="video/*" required>
586
  <button class="btn" type="submit">Uploader</button>
587
  </form>
588
- <button class="btn" id="btnWarmup" type="button">Warm-up (préparer les modèles)</button>
589
  <span class="muted" id="msg">__MSG__</span>
590
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
591
  </div>
@@ -617,15 +567,15 @@ HTML_TEMPLATE = r"""
617
  <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
618
  <button id="gotoBtn" class="btn">Aller</button>
619
  <span class="muted" id="maskedCount"></span>
620
- <!-- Ajouts : Undo/Redo boutons -->
621
- <button id="btnUndo" class="btn">↩️ Undo</button>
622
- <button id="btnRedo" class="btn">↪️ Redo</button>
623
  </div>
624
  <div id="timeline" class="timeline"></div>
625
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
626
  <div id="loading-indicator">Chargement des frames...</div>
627
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
628
- <!-- Ajout : Barre progression IA -->
629
  <div id="progressBar"><div id="progressFill"></div></div>
630
  <div>Progression IA: <span id="progressLog">En attente...</span></div>
631
  </div>
@@ -637,8 +587,8 @@ HTML_TEMPLATE = r"""
637
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
638
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
639
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
640
- <!-- Ajout : Bouton Preview IA -->
641
- <button id="btnPreview" class="btn">🔍 Preview IA</button>
642
  </div>
643
  <div style="margin-top:10px">
644
  <div class="muted">Couleur</div>
@@ -669,7 +619,7 @@ HTML_TEMPLATE = r"""
669
  <div id="popup-logs"></div>
670
  </div>
671
  <div id="toast"></div>
672
- <!-- Ajout : Tutoriel masquable -->
673
  <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>
674
  <script>
675
  const serverVid = "__VID__";
@@ -709,13 +659,12 @@ const hud = document.getElementById('hud');
709
  const toastWrap = document.getElementById('toast');
710
  const gotoInput = document.getElementById('gotoInput');
711
  const gotoBtn = document.getElementById('gotoBtn');
712
- // Nouveaux éléments
713
  const btnUndo = document.getElementById('btnUndo');
714
  const btnRedo = document.getElementById('btnRedo');
715
  const btnPreview = document.getElementById('btnPreview');
716
  const progressFill = document.getElementById('progressFill');
717
  const progressLog = document.getElementById('progressLog');
718
- const btnWarmup = document.getElementById('btnWarmup');
719
  // State
720
  let vidName = serverVid || '';
721
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -1061,7 +1010,7 @@ async function loadVideoAndMeta() {
1061
  vidStem = fileStem(vidName); bustToken = Date.now();
1062
  const bust = Date.now();
1063
  srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1064
- player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
1065
  player.load();
1066
  fitCanvas();
1067
  statusEl.textContent = 'Chargement vidéo…';
@@ -1241,12 +1190,59 @@ btnSave.onclick = async ()=>{
1241
  alert('Erreur réseau lors de l’enregistrement du masque.');
1242
  }
1243
  };
1244
- // Boot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1245
  async function boot(){
1246
- const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1247
  await loadFiles();
1248
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1249
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
 
 
 
 
 
1250
  }
1251
  boot();
1252
  // Hide controls in edit-mode
 
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
  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
  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
  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
  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
  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
  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
  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
  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">
 
536
  <input type="file" name="file" accept="video/*" required>
537
  <button class="btn" type="submit">Uploader</button>
538
  </form>
 
539
  <span class="muted" id="msg">__MSG__</span>
540
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
541
  </div>
 
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>
 
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
  <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__";
 
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; }
 
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…';
 
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