FABLESLIP commited on
Commit
8ce84cd
·
verified ·
1 Parent(s): 72d5c48

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +336 -158
app.py CHANGED
@@ -1,37 +1,29 @@
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
9
  from pathlib import Path
10
- from typing import Optional, Dict, Any
11
  import uuid, shutil, cv2, json, time, urllib.parse, sys
12
  import threading
13
  import subprocess
14
  import shutil as _shutil
15
- # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------
 
 
 
 
16
  import os
17
  import httpx
18
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
19
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
20
  _backend_url_cache = {"url": None, "ts": 0.0}
21
  def get_backend_base() -> str:
22
- """
23
- Renvoie l'URL du backend.
24
- - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
25
- l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
26
- - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
27
- """
28
  try:
29
  if POINTER_URL:
30
  now = time.time()
31
- need_refresh = (
32
- not _backend_url_cache["url"] or
33
- now - _backend_url_cache["ts"] > 30
34
- )
35
  if need_refresh:
36
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
37
  url = (r.text or "").strip()
@@ -48,7 +40,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"
@@ -75,8 +67,9 @@ async def proxy_all(full_path: str, request: Request):
75
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
76
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
77
  # -------------------------------------------------------------------------------
78
- # Global progress dict (vid_stem -> {percent, logs, done})
79
  progress_data: Dict[str, Dict[str, Any]] = {}
 
80
  # ---------- Helpers ----------
81
  def _is_video(p: Path) -> bool:
82
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
@@ -85,7 +78,6 @@ def _safe_name(name: str) -> str:
85
  def _has_ffmpeg() -> bool:
86
  return _shutil.which("ffmpeg") is not None
87
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
88
- # Utilisation en subprocess (pas shell), on échappe la virgule.
89
  return f"scale=min(iw\\,{max_w}):-2"
90
  def _meta(video: Path):
91
  cap = cv2.VideoCapture(str(video))
@@ -100,10 +92,6 @@ def _meta(video: Path):
100
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
101
  return {"frames": frames, "fps": fps, "w": w, "h": h}
102
  def _frame_jpg(video: Path, idx: int) -> Path:
103
- """
104
- Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
105
- Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
106
- """
107
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
108
  if out.exists():
109
  return out
@@ -141,7 +129,6 @@ def _frame_jpg(video: Path, idx: int) -> Path:
141
  if not ok or img is None:
142
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
143
  raise HTTPException(500, "Impossible de lire la frame demandée.")
144
- # Redimension (≈320 px)
145
  h, w = img.shape[:2]
146
  if w > 320:
147
  new_w = 320
@@ -176,11 +163,6 @@ def _load_masks(vid: str) -> Dict[str, Any]:
176
  def _save_masks(vid: str, data: Dict[str, Any]):
177
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
178
  def _gen_thumbs_background(video: Path, vid_stem: str):
179
- """
180
- Génère toutes les vignettes en arrière-plan :
181
- - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
182
- - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
183
- """
184
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
185
  try:
186
  m = _meta(video)
@@ -193,7 +175,6 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
193
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
194
  progress_data[vid_stem]['done'] = True
195
  return
196
- # Nettoyer d’anciennes thumbs du même stem
197
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
198
  f.unlink(missing_ok=True)
199
  if _has_ffmpeg():
@@ -237,7 +218,6 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
237
  if not ok or img is None:
238
  break
239
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
240
- # Redimension léger (≈320 px de large)
241
  h, w = img.shape[:2]
242
  if w > 320:
243
  new_w = 320
@@ -258,12 +238,157 @@ 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
- # ---------- 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():
@@ -370,56 +495,6 @@ def window(vid: str, center: int = 0, count: int = 21):
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>
@@ -454,7 +529,7 @@ HTML_TEMPLATE = r"""
454
  .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
455
  .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
456
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
457
- ul.clean{list-style:none;padding-left=0;margin:6px 0}
458
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
459
  .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
460
  .rename-btn:hover{color:#2563eb}
@@ -475,11 +550,14 @@ HTML_TEMPLATE = r"""
475
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
476
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
477
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
478
- /* Nouveaux styles pour améliorations (non-intrusifs) */
479
- #progressBar { width: 100%; height: 20px; background: #e5e7eb; border-radius: 4px; margin: 10px 0; }
480
- #progressFill { height: 100%; background: #2563eb; width: 0%; transition: width 0.5s; }
481
- #tutorial { background: #fef3c7; padding: 10px; border: 1px solid #fbbf24; border-radius: 4px; margin: 10px 0; cursor: pointer; }
482
- #tutorial.hidden { display: none; }
 
 
 
483
  </style>
484
  <h1>🎬 Video Editor</h1>
485
  <div class="topbar card">
@@ -533,6 +611,8 @@ HTML_TEMPLATE = r"""
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>
@@ -548,13 +628,18 @@ HTML_TEMPLATE = r"""
548
  <details open>
549
  <summary><strong>Masques</strong></summary>
550
  <div id="maskList" class="muted">—</div>
 
551
  <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
 
552
  </details>
 
 
553
  <div style="margin-top:6px">
554
  <strong>Vidéos disponibles</strong>
555
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
556
  </div>
557
  </div>
 
558
  </div>
559
  </div>
560
  <div id="popup">
@@ -563,11 +648,16 @@ HTML_TEMPLATE = r"""
563
  <div id="popup-logs"></div>
564
  </div>
565
  <div id="toast"></div>
 
 
 
 
 
566
  <script>
567
  const serverVid = "__VID__";
568
  const serverMsg = "__MSG__";
569
  document.getElementById('msg').textContent = serverMsg;
570
- // Elements
571
  const statusEl = document.getElementById('status');
572
  const player = document.getElementById('player');
573
  const srcEl = document.getElementById('vidsrc');
@@ -601,7 +691,15 @@ const hud = document.getElementById('hud');
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; }
607
  let vidStem = '';
@@ -609,9 +707,12 @@ let bustToken = Date.now();
609
  let fps = 30, frames = 0;
610
  let currentIdx = 0;
611
  let mode = 'view';
612
- let rect = null, dragging=false, sx=0, sy=0;
 
613
  let color = '#10b981';
614
- let rectMap = new Map();
 
 
615
  let masks = [];
616
  let maskedSet = new Set();
617
  let timelineUrls = [];
@@ -620,7 +721,7 @@ let portionEnd = null;
620
  let loopInterval = null;
621
  let chunkSize = 50;
622
  let timelineStart = 0, timelineEnd = 0;
623
- let viewRangeStart = 0, viewRangeEnd = 0; // portée visible (portion ou full)
624
  const scrollThreshold = 100;
625
  let followMode = false;
626
  let isPaused = true;
@@ -629,7 +730,7 @@ let lastCenterMs = 0;
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(){
635
  if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
@@ -652,7 +753,6 @@ function rawCenterThumb(el){
652
  tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
653
  }
654
  async function ensureThumbVisibleCentered(idx){
655
- // Attendre que l’élément + image soient prêts avant de centrer
656
  for(let k=0; k<40; k++){
657
  const el = findThumbEl(idx);
658
  if(el){
@@ -713,6 +813,15 @@ async function flushPending(){
713
  savePendingList(kept);
714
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
715
  }
 
 
 
 
 
 
 
 
 
716
  // Layout
717
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
718
  function fitCanvas(){
@@ -735,52 +844,74 @@ function setMode(m){
735
  modeLabel.textContent='Édition';
736
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
737
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
 
738
  canvas.style.pointerEvents='auto';
739
- rect = rectMap.get(currentIdx) || null; draw();
740
  }else{
741
  player.controls = true;
742
  playerWrap.classList.remove('edit-mode');
743
  modeLabel.textContent='Lecture';
744
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
745
  btnSave.style.display='none'; btnClear.style.display='none';
 
746
  canvas.style.pointerEvents='none';
747
- rect=null; draw();
748
  }
749
  }
750
  function draw(){
751
  ctx.clearRect(0,0,canvas.width,canvas.height);
752
- if(rect){
753
- const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
754
- const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
755
- ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
756
- ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
757
- }
758
  }
759
  canvas.addEventListener('mousedown',(e)=>{
760
  if(mode!=='edit' || !vidName) return;
761
  dragging=true; const r=canvas.getBoundingClientRect();
762
  sx=e.clientX-r.left; sy=e.clientY-r.top;
763
- rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
764
  });
765
  canvas.addEventListener('mousemove',(e)=>{
766
  if(!dragging) return;
767
  const r=canvas.getBoundingClientRect();
768
- rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
769
  });
770
- ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
771
- btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  btnEdit.onclick =()=> setMode('edit');
773
  btnBack.onclick =()=> setMode('view');
774
- // Palette
775
  palette.querySelectorAll('.swatch').forEach(el=>{
776
  if(el.dataset.c===color) el.classList.add('sel');
777
  el.onclick=()=>{
778
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
779
  el.classList.add('sel'); color=el.dataset.c;
780
- if(rect){ rect.color=color; draw(); }
781
  };
782
  });
783
- // === Timeline ===
784
  async function loadTimelineUrls(){
785
  timelineUrls = [];
786
  const stem = vidStem, b = bustToken;
@@ -845,7 +976,7 @@ function addThumb(idx, place='append'){
845
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
846
  img.onclick=async ()=>{
847
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
848
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
849
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
850
  };
851
  wrap.appendChild(img);
@@ -856,7 +987,6 @@ function addThumb(idx, place='append'){
856
  else{ tlBox.appendChild(wrap); }
857
  thumbEls.set(idx, wrap);
858
  }
859
- // Scroll chunk (mode normal uniquement)
860
  tlBox.addEventListener('scroll', ()=>{
861
  if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
862
  updatePlayhead(); updatePortionOverlays();
@@ -876,7 +1006,6 @@ tlBox.addEventListener('scroll', ()=>{
876
  }
877
  updatePlayhead(); updatePortionOverlays();
878
  });
879
- // Isoler & Boucle
880
  isolerBoucle.onclick = async ()=>{
881
  const start = parseInt(goFrame.value || '1',10) - 1;
882
  const end = parseInt(endPortion.value || '',10);
@@ -905,7 +1034,6 @@ resetFull.onclick = async ()=>{
905
  resetFull.style.display='none';
906
  clearInterval(loopInterval); updatePortionOverlays();
907
  };
908
- // Drag IN/OUT
909
  function attachHandleDrag(handle, which){
910
  let draggingH=false;
911
  function onMove(e){
@@ -921,7 +1049,6 @@ function attachHandleDrag(handle, which){
921
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
922
  }
923
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
924
- // Progress popup
925
  async function showProgress(vidStem){
926
  popup.style.display = 'block';
927
  const interval = setInterval(async () => {
@@ -937,13 +1064,12 @@ async function showProgress(vidStem){
937
  }
938
  }, 800);
939
  }
940
- // Meta & boot
941
  async function loadVideoAndMeta() {
942
  if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
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…';
@@ -971,7 +1097,7 @@ player.addEventListener('loadedmetadata', async ()=>{
971
  if(!frames || frames<=0){
972
  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{}
973
  }
974
- currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
975
  });
976
  window.addEventListener('resize', ()=>{ fitCanvas(); });
977
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
@@ -979,7 +1105,7 @@ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); }
979
  player.addEventListener('timeupdate', ()=>{
980
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
981
  currentIdx=timeToIdx(player.currentTime);
982
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
983
  updateHUD(); updateSelectedThumb(); updatePlayhead();
984
  if(followMode && !isPaused){
985
  const now = Date.now();
@@ -991,11 +1117,10 @@ goFrame.addEventListener('change', async ()=>{
991
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
992
  player.pause(); isPaused = true;
993
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
994
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
995
  await renderTimeline(currentIdx);
996
  await ensureThumbVisibleCentered(currentIdx);
997
  });
998
- // Follow / Filter / Zoom / Goto
999
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1000
  btnFilterMasked.onclick = async ()=>{
1001
  maskedOnlyMode = !maskedOnlyMode;
@@ -1016,7 +1141,7 @@ async function gotoFrameNum(){
1016
  }
1017
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1018
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1019
- // Drag & drop upload
1020
  const uploadZone = document.getElementById('uploadForm');
1021
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1022
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
@@ -1028,9 +1153,9 @@ uploadZone.addEventListener('drop', (e) => {
1028
  fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1029
  }
1030
  });
1031
- // Export placeholder
1032
  document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1033
- // Fichiers & masques
1034
  async function loadFiles(){
1035
  const r=await fetch('/files'); const d=await r.json();
1036
  if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
@@ -1038,7 +1163,7 @@ async function loadFiles(){
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(); }
@@ -1053,13 +1178,16 @@ async function loadMasks(){
1053
  const r=await fetch('/mask/'+encodeURIComponent(vidName));
1054
  const d=await r.json();
1055
  masks=d.masks||[];
1056
- maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1057
  rectMap.clear();
1058
  masks.forEach(m=>{
 
 
 
1059
  if(m.shape==='rect'){
1060
  const [x1,y1,x2,y2] = m.points;
1061
  const normW = canvas.clientWidth, normH = canvas.clientHeight;
1062
- rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1063
  }
1064
  });
1065
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
@@ -1074,7 +1202,7 @@ async function loadMasks(){
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,7 +1211,7 @@ async function loadMasks(){
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 {
@@ -1094,44 +1222,94 @@ async function loadMasks(){
1094
  });
1095
  box.appendChild(ul);
1096
  loadingInd.style.display='none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1097
  }
1098
- // Save mask (+ cache)
1099
  btnSave.onclick = async ()=>{
1100
- if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1101
- const defaultName = `frame ${currentIdx+1}`;
1102
- const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1103
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
1104
- const x=Math.min(rect.x1,rect.x2)/normW;
1105
- const y=Math.min(rect.y1,rect.y2)/normH;
1106
- const w=Math.abs(rect.x2-rect.x1)/normW;
1107
- const h=Math.abs(rect.y2-rect.y1)/normH;
1108
- 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};
1109
- addPending(payload);
1110
- try{
1111
- const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1112
- if(r.ok){
1113
- const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1114
- savePendingList(lst);
1115
- rectMap.set(currentIdx,{...rect});
1116
- await loadMasks(); await renderTimeline(currentIdx);
1117
- showToast('Masque enregistré ✅');
1118
- } else {
1119
- const txt = await r.text();
1120
- alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
 
 
 
 
 
 
 
 
 
1121
  }
1122
- }catch(e){
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
1135
  const style = document.createElement('style');
1136
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1137
  document.head.appendChild(style);
 
1
+ # app.py — Video Editor API (v0.8.0)
2
+ # v0.8.0: Chargement modèles Hub via /warmup, stubs IA, + Améliorations : multi-masques, estimation /estimate, progression /progress_ia, undo/redo via history, auto-save pending étendu
 
 
 
3
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
4
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
5
  from fastapi.staticfiles import StaticFiles
6
  from pathlib import Path
7
+ from typing import Optional, Dict, Any, List
8
  import uuid, shutil, cv2, json, time, urllib.parse, sys
9
  import threading
10
  import subprocess
11
  import shutil as _shutil
12
+ import os
13
+ import httpx
14
+ import huggingface_hub as hf
15
+ from joblib import Parallel, delayed
16
+ # --- POINTEUR (inchangé) ---
17
  import os
18
  import httpx
19
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
20
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
21
  _backend_url_cache = {"url": None, "ts": 0.0}
22
  def get_backend_base() -> str:
 
 
 
 
 
 
23
  try:
24
  if POINTER_URL:
25
  now = time.time()
26
+ need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
 
 
 
27
  if need_refresh:
28
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
29
  url = (r.text or "").strip()
 
40
  print("[BOOT] Video Editor API starting…")
41
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
42
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
43
+ app = FastAPI(title="Video Editor API", version="0.8.0")
44
  DATA_DIR = Path("/app/data")
45
  THUMB_DIR = DATA_DIR / "_thumbs"
46
  MASK_DIR = DATA_DIR / "_masks"
 
67
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
68
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
69
  # -------------------------------------------------------------------------------
70
+ # Global progress dict (vid_stem -> {percent, logs, done}) + warmup_progress
71
  progress_data: Dict[str, Dict[str, Any]] = {}
72
+ warmup_progress: Dict[str, Any] = {'percent': 0, 'logs': [], 'done': False, 'in_progress': False}
73
  # ---------- Helpers ----------
74
  def _is_video(p: Path) -> bool:
75
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
 
78
  def _has_ffmpeg() -> bool:
79
  return _shutil.which("ffmpeg") is not None
80
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
 
81
  return f"scale=min(iw\\,{max_w}):-2"
82
  def _meta(video: Path):
83
  cap = cv2.VideoCapture(str(video))
 
92
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
93
  return {"frames": frames, "fps": fps, "w": w, "h": h}
94
  def _frame_jpg(video: Path, idx: int) -> Path:
 
 
 
 
95
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
96
  if out.exists():
97
  return out
 
129
  if not ok or img is None:
130
  print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
131
  raise HTTPException(500, "Impossible de lire la frame demandée.")
 
132
  h, w = img.shape[:2]
133
  if w > 320:
134
  new_w = 320
 
163
  def _save_masks(vid: str, data: Dict[str, Any]):
164
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
165
  def _gen_thumbs_background(video: Path, vid_stem: str):
 
 
 
 
 
166
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
167
  try:
168
  m = _meta(video)
 
175
  progress_data[vid_stem]['logs'].append("Aucune frame détectée")
176
  progress_data[vid_stem]['done'] = True
177
  return
 
178
  for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
179
  f.unlink(missing_ok=True)
180
  if _has_ffmpeg():
 
218
  if not ok or img is None:
219
  break
220
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
 
221
  h, w = img.shape[:2]
222
  if w > 320:
223
  new_w = 320
 
238
  except Exception as e:
239
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
240
  progress_data[vid_stem]['done'] = True
241
+ def is_gpu():
242
+ # TODO: Détecter si GPU actif (via torch ou env)
243
+ return False
244
+ # --- Chargement Modèles (Warm-up séquentiel avec reprises) ---
245
+ models = [
246
+ "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
247
+ "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
248
+ ]
249
+ PROP_URLS = [
250
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth",
251
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth",
252
+ "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth"
253
+ ]
254
+ def load_model(repo_id, retry=3):
255
+ path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
256
+ for attempt in range(retry):
257
+ try:
258
+ if not path.exists() or not any(path.iterdir()):
259
+ warmup_progress['logs'].append(f"Downloading {repo_id} (attempt {attempt+1})...")
260
+ hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
261
+ (path / "loaded.ok").touch()
262
+ if "sam2" in repo_id:
263
+ shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
264
+ warmup_progress['logs'].append(f"{repo_id} ready.")
265
+ return True
266
+ except Exception as e:
267
+ warmup_progress['logs'].append(f"Error {repo_id}: {e} (retrying...)")
268
+ return False
269
+ def wget_prop(url, dest, retry=3):
270
+ fname = url.split("/")[-1]
271
+ out = dest / fname
272
+ for attempt in range(retry):
273
+ try:
274
+ if not out.exists():
275
+ warmup_progress['logs'].append(f"Downloading {fname} (attempt {attempt+1})...")
276
+ subprocess.run(["wget", "-q", url, "-P", str(dest)], check=True)
277
+ warmup_progress['logs'].append(f"{fname} ready.")
278
+ return True
279
+ except Exception as e:
280
+ warmup_progress['logs'].append(f"Error {fname}: {e} (retrying...)")
281
+ return False
282
+ @app.post("/warmup")
283
+ def warmup():
284
+ if warmup_progress['in_progress']:
285
+ raise HTTPException(400, "Warm-up already in progress.")
286
+ warmup_progress['in_progress'] = True
287
+ warmup_progress['percent'] = 0
288
+ warmup_progress['logs'] = ["Warm-up started."]
289
+ threading.Thread(target=_run_warmup, daemon=True).start()
290
+ return {"ok": True}
291
+ def _run_warmup():
292
+ total = len(models) + len(PROP_URLS)
293
+ done = 0
294
+ for m in models:
295
+ if load_model(m):
296
+ done += 1
297
+ warmup_progress['percent'] = int((done / total) * 100)
298
+ else:
299
+ warmup_progress['logs'].append(f"Failed {m} after retries.")
300
+ PROP = Path("/app/propainter")
301
+ PROP.mkdir(exist_ok=True)
302
+ for url in PROP_URLS:
303
+ if wget_prop(url, PROP):
304
+ done += 1
305
+ warmup_progress['percent'] = int((done / total) * 100)
306
+ else:
307
+ warmup_progress['logs'].append(f"Failed {url.split('/')[-1]} after retries.")
308
+ warmup_progress['done'] = True
309
+ warmup_progress['in_progress'] = False
310
+ warmup_progress['logs'].append("Warm-up complete.")
311
+ @app.get("/warmup_progress")
312
+ def get_warmup_progress():
313
+ return warmup_progress
314
+ # --- API (Ajouts IA stubs + Nouveaux pour Améliorations) ---
315
+ @app.post("/mask/ai")
316
+ async def mask_ai(payload: Dict[str, Any] = Body(...)):
317
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
318
+ # TODO: Impl SAM2
319
+ return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
320
+ @app.post("/inpaint")
321
+ async def inpaint(payload: Dict[str, Any] = Body(...)):
322
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
323
+ # TODO: Impl DiffuEraser, update progress_ia
324
+ return {"ok": True, "preview": "/data/preview.mp4"}
325
+ @app.get("/estimate")
326
+ def estimate(vid: str, masks_count: int):
327
+ # TODO: Calcul simple (frames * masks * facteur GPU)
328
+ return {"time_min": 5, "vram_gb": 4}
329
+ @app.get("/progress_ia")
330
+ def progress_ia(vid: str):
331
+ # TODO: Retourne % et logs (e.g., {"percent": 50, "log": "Frame 25/50"})
332
+ return {"percent": 0, "log": "En cours..."}
333
+ # Masques étendus pour multi par frame (masks est list, filtrable par frame_idx)
334
+ @app.post("/mask", tags=["mask"])
335
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
336
+ vid = payload.get("vid")
337
+ if not vid:
338
+ raise HTTPException(400, "vid manquant")
339
+ pts = payload.get("points") or []
340
+ if len(pts) != 4:
341
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
342
+ data = _load_masks(vid)
343
+ m = {
344
+ "id": uuid.uuid4().hex[:10],
345
+ "time_s": float(payload.get("time_s") or 0.0),
346
+ "frame_idx": int(payload.get("frame_idx") or 0),
347
+ "shape": "rect",
348
+ "points": [float(x) for x in pts],
349
+ "color": payload.get("color") or "#10b981",
350
+ "note": payload.get("note") or ""
351
+ }
352
+ data.setdefault("masks", []).append(m)
353
+ _save_masks(vid, data)
354
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
355
+ return {"saved": True, "mask": m}
356
+ @app.get("/mask/{vid}", tags=["mask"])
357
+ def list_masks(vid: str, frame_idx: Optional[int] = None):
358
+ data = _load_masks(vid)
359
+ if frame_idx is not None:
360
+ return [m for m in data["masks"] if m["frame_idx"] == frame_idx]
361
+ return data
362
+ @app.post("/mask/rename", tags=["mask"])
363
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
364
+ vid = payload.get("vid")
365
+ mid = payload.get("id")
366
+ new_note = (payload.get("note") or "").strip()
367
+ if not vid or not mid:
368
+ raise HTTPException(400, "vid et id requis")
369
+ data = _load_masks(vid)
370
+ for m in data.get("masks", []):
371
+ if m.get("id") == mid:
372
+ m["note"] = new_note
373
+ _save_masks(vid, data)
374
+ return {"ok": True}
375
+ raise HTTPException(404, "Masque introuvable")
376
+ @app.post("/mask/delete", tags=["mask"])
377
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
378
+ vid = payload.get("vid")
379
+ mid = payload.get("id")
380
+ if not vid or not mid:
381
+ raise HTTPException(400, "vid et id requis")
382
+ data = _load_masks(vid)
383
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
384
+ _save_masks(vid, data)
385
+ return {"ok": True}
386
+ # Autres routes inchangées
387
  @app.get("/", tags=["meta"])
388
  def root():
389
  return {
390
  "ok": True,
391
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup", "/warmup_progress", "/mask/ai", "/inpaint", "/estimate", "/progress_ia"]
392
  }
393
  @app.get("/health", tags=["meta"])
394
  def health():
 
495
  items.append({"i": i, "idx": idx, "url": url})
496
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
497
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  # ---------- UI ----------
499
  HTML_TEMPLATE = r"""
500
  <!doctype html>
 
529
  .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
530
  .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
531
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
532
+ ul.clean{list-style:none;padding-left:0;margin:6px 0}
533
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
534
  .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
535
  .rename-btn:hover{color:#2563eb}
 
550
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
551
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
552
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
553
+ #tutorial {background:#fef9c3;padding:10px;border-radius:8px;margin-top:12px;display:block}
554
+ #tutorial.hidden {display:none}
555
+ #warmupBtn {margin-top:8px}
556
+ #iaPreviewBtn {margin-top:8px}
557
+ #iaProgress {margin-top:8px; background:#f3f4f6;border-radius:4px;height:8px}
558
+ #iaProgressFill {background:#2563eb;height:100%;width:0;border-radius:4px}
559
+ #iaLogs {font-size:12px;color:#6b7280;margin-top:4px}
560
+ #multiMaskList {max-height:150px;overflow:auto}
561
  </style>
562
  <h1>🎬 Video Editor</h1>
563
  <div class="topbar card">
 
611
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
612
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
613
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
614
+ <button id="undoBtn" class="btn" style="display:none">↩️ Undo</button>
615
+ <button id="redoBtn" class="btn" style="display:none">↪️ Redo</button>
616
  </div>
617
  <div style="margin-top:10px">
618
  <div class="muted">Couleur</div>
 
628
  <details open>
629
  <summary><strong>Masques</strong></summary>
630
  <div id="maskList" class="muted">—</div>
631
+ <div id="multiMaskList" class="muted">—</div>
632
  <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
633
+ <button class="btn" id="iaPreviewBtn" title="Preview IA (en développement)">🔍 Preview IA</button>
634
  </details>
635
+ <div id="iaProgress"><div id="iaProgressFill"></div></div>
636
+ <div id="iaLogs"></div>
637
  <div style="margin-top:6px">
638
  <strong>Vidéos disponibles</strong>
639
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
640
  </div>
641
  </div>
642
+ <button class="btn" id="warmupBtn">Warm-up Modèles</button>
643
  </div>
644
  </div>
645
  <div id="popup">
 
648
  <div id="popup-logs"></div>
649
  </div>
650
  <div id="toast"></div>
651
+ <div id="tutorial">
652
+ <h4>Tutoriel</h4>
653
+ <p>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</p>
654
+ <button onclick="this.parentElement.classList.add('hidden')">Masquer</button>
655
+ </div>
656
  <script>
657
  const serverVid = "__VID__";
658
  const serverMsg = "__MSG__";
659
  document.getElementById('msg').textContent = serverMsg;
660
+ // Elements (ajouts pour nouvelles features)
661
  const statusEl = document.getElementById('status');
662
  const player = document.getElementById('player');
663
  const srcEl = document.getElementById('vidsrc');
 
691
  const toastWrap = document.getElementById('toast');
692
  const gotoInput = document.getElementById('gotoInput');
693
  const gotoBtn = document.getElementById('gotoBtn');
694
+ const tutorial = document.getElementById('tutorial');
695
+ const warmupBtn = document.getElementById('warmupBtn');
696
+ const iaPreviewBtn = document.getElementById('iaPreviewBtn');
697
+ const iaProgressFill = document.getElementById('iaProgressFill');
698
+ const iaLogs = document.getElementById('iaLogs');
699
+ const undoBtn = document.getElementById('undoBtn');
700
+ const redoBtn = document.getElementById('redoBtn');
701
+ const multiMaskList = document.getElementById('multiMaskList');
702
+ // State (ajouts multi-rects, history pour undo/redo, auto-save)
703
  let vidName = serverVid || '';
704
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
705
  let vidStem = '';
 
707
  let fps = 30, frames = 0;
708
  let currentIdx = 0;
709
  let mode = 'view';
710
+ let rects = []; // list of {x1,y1,x2,y2,color} for current frame
711
+ let dragging=false, sx=0, sy=0, currentRect = null;
712
  let color = '#10b981';
713
+ let rectMap = new Map(); // idx -> array of rects
714
+ let history = []; // for undo/redo per frame
715
+ let historyIdx = -1;
716
  let masks = [];
717
  let maskedSet = new Set();
718
  let timelineUrls = [];
 
721
  let loopInterval = null;
722
  let chunkSize = 50;
723
  let timelineStart = 0, timelineEnd = 0;
724
+ let viewRangeStart = 0, viewRangeEnd = 0;
725
  const scrollThreshold = 100;
726
  let followMode = false;
727
  let isPaused = true;
 
730
  const CENTER_THROTTLE_MS = 150;
731
  const PENDING_KEY = 've_pending_masks_v1';
732
  let maskedOnlyMode = false;
733
+ // Utils (ajouts)
734
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
735
  function ensureOverlays(){
736
  if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
 
753
  tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
754
  }
755
  async function ensureThumbVisibleCentered(idx){
 
756
  for(let k=0; k<40; k++){
757
  const el = findThumbEl(idx);
758
  if(el){
 
813
  savePendingList(kept);
814
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
815
  }
816
+ // Auto-save extension: save current rects to local if not saved
817
+ const AUTOSAVE_KEY = 've_autosave_rects_v1';
818
+ function saveAutoRects(){ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({vid:vidName, idx:currentIdx, rects:rects})); }
819
+ function loadAutoRects(){
820
+ try{
821
+ const d = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) || '{}');
822
+ if(d.vid === vidName && d.idx === currentIdx){ rects = d.rects || []; draw(); }
823
+ }catch{}
824
+ }
825
  // Layout
826
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
827
  function fitCanvas(){
 
844
  modeLabel.textContent='Édition';
845
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
846
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
847
+ undoBtn.style.display='inline-block'; redoBtn.style.display='inline-block';
848
  canvas.style.pointerEvents='auto';
849
+ rects = rectMap.get(currentIdx) || []; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks();
850
  }else{
851
  player.controls = true;
852
  playerWrap.classList.remove('edit-mode');
853
  modeLabel.textContent='Lecture';
854
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
855
  btnSave.style.display='none'; btnClear.style.display='none';
856
+ undoBtn.style.display='none'; redoBtn.style.display='none';
857
  canvas.style.pointerEvents='none';
858
+ rects=[]; draw();
859
  }
860
  }
861
  function draw(){
862
  ctx.clearRect(0,0,canvas.width,canvas.height);
863
+ rects.forEach(r => {
864
+ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2);
865
+ const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1);
866
+ ctx.strokeStyle=r.color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
867
+ ctx.fillStyle=r.color+'28'; ctx.fillRect(x,y,w,h);
868
+ });
869
  }
870
  canvas.addEventListener('mousedown',(e)=>{
871
  if(mode!=='edit' || !vidName) return;
872
  dragging=true; const r=canvas.getBoundingClientRect();
873
  sx=e.clientX-r.left; sy=e.clientY-r.top;
874
+ currentRect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; rects.push(currentRect); draw(); saveAutoRects();
875
  });
876
  canvas.addEventListener('mousemove',(e)=>{
877
  if(!dragging) return;
878
  const r=canvas.getBoundingClientRect();
879
+ currentRect.x2=e.clientX-r.left; currentRect.y2=e.clientY-r.top; draw();
880
  });
881
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{
882
+ dragging=false;
883
+ if(currentRect){ pushHistory(); }
884
+ }));
885
+ function pushHistory(){
886
+ history = history.slice(0, historyIdx + 1);
887
+ history.push(JSON.stringify(rects));
888
+ historyIdx++;
889
+ updateUndoRedo();
890
+ saveAutoRects();
891
+ }
892
+ function updateUndoRedo(){
893
+ undoBtn.disabled = historyIdx <= 0;
894
+ redoBtn.disabled = historyIdx >= history.length - 1;
895
+ }
896
+ undoBtn.onclick = () => {
897
+ if(historyIdx > 0){ historyIdx--; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
898
+ };
899
+ redoBtn.onclick = () => {
900
+ if(historyIdx < history.length - 1){ historyIdx++; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
901
+ };
902
+ btnClear.onclick=()=>{ rects.pop(); pushHistory(); draw(); saveAutoRects(); };
903
  btnEdit.onclick =()=> setMode('edit');
904
  btnBack.onclick =()=> setMode('view');
905
+ // Palette (inchangé)
906
  palette.querySelectorAll('.swatch').forEach(el=>{
907
  if(el.dataset.c===color) el.classList.add('sel');
908
  el.onclick=()=>{
909
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
910
  el.classList.add('sel'); color=el.dataset.c;
911
+ if(currentRect){ currentRect.color=color; draw(); }
912
  };
913
  });
914
+ // Timeline (inchangé, sauf appel loadMultiMasks dans loadMasks)
915
  async function loadTimelineUrls(){
916
  timelineUrls = [];
917
  const stem = vidStem, b = bustToken;
 
976
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
977
  img.onclick=async ()=>{
978
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
979
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
980
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
981
  };
982
  wrap.appendChild(img);
 
987
  else{ tlBox.appendChild(wrap); }
988
  thumbEls.set(idx, wrap);
989
  }
 
990
  tlBox.addEventListener('scroll', ()=>{
991
  if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
992
  updatePlayhead(); updatePortionOverlays();
 
1006
  }
1007
  updatePlayhead(); updatePortionOverlays();
1008
  });
 
1009
  isolerBoucle.onclick = async ()=>{
1010
  const start = parseInt(goFrame.value || '1',10) - 1;
1011
  const end = parseInt(endPortion.value || '',10);
 
1034
  resetFull.style.display='none';
1035
  clearInterval(loopInterval); updatePortionOverlays();
1036
  };
 
1037
  function attachHandleDrag(handle, which){
1038
  let draggingH=false;
1039
  function onMove(e){
 
1049
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1050
  }
1051
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
 
1052
  async function showProgress(vidStem){
1053
  popup.style.display = 'block';
1054
  const interval = setInterval(async () => {
 
1064
  }
1065
  }, 800);
1066
  }
 
1067
  async function loadVideoAndMeta() {
1068
  if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1069
  vidStem = fileStem(vidName); bustToken = Date.now();
1070
  const bust = Date.now();
1071
  srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1072
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust;
1073
  player.load();
1074
  fitCanvas();
1075
  statusEl.textContent = 'Chargement vidéo…';
 
1097
  if(!frames || frames<=0){
1098
  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{}
1099
  }
1100
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rects=[]; draw();
1101
  });
1102
  window.addEventListener('resize', ()=>{ fitCanvas(); });
1103
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
 
1105
  player.addEventListener('timeupdate', ()=>{
1106
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1107
  currentIdx=timeToIdx(player.currentTime);
1108
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1109
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1110
  if(followMode && !isPaused){
1111
  const now = Date.now();
 
1117
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1118
  player.pause(); isPaused = true;
1119
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1120
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1121
  await renderTimeline(currentIdx);
1122
  await ensureThumbVisibleCentered(currentIdx);
1123
  });
 
1124
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1125
  btnFilterMasked.onclick = async ()=>{
1126
  maskedOnlyMode = !maskedOnlyMode;
 
1141
  }
1142
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1143
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1144
+ // Drag & drop upload (inchangé)
1145
  const uploadZone = document.getElementById('uploadForm');
1146
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1147
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
 
1153
  fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1154
  }
1155
  });
1156
+ // Export placeholder (inchangé)
1157
  document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1158
+ // Fichiers & masques (ajouts multi + loadAutoRects)
1159
  async function loadFiles(){
1160
  const r=await fetch('/files'); const d=await r.json();
1161
  if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
 
1163
  d.items.forEach(name=>{
1164
  const li=document.createElement('li');
1165
  const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1166
+ delBtn.onclick=async()=>{
1167
  if(!confirm(`Supprimer "${name}" ?`)) return;
1168
  await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1169
  loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
 
1178
  const r=await fetch('/mask/'+encodeURIComponent(vidName));
1179
  const d=await r.json();
1180
  masks=d.masks||[];
1181
+ maskedSet = new Set();
1182
  rectMap.clear();
1183
  masks.forEach(m=>{
1184
+ const idx = m.frame_idx;
1185
+ maskedSet.add(idx);
1186
+ if(!rectMap.has(idx)) rectMap.set(idx, []);
1187
  if(m.shape==='rect'){
1188
  const [x1,y1,x2,y2] = m.points;
1189
  const normW = canvas.clientWidth, normH = canvas.clientHeight;
1190
+ rectMap.get(idx).push({x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color, id:m.id, note:m.note});
1191
  }
1192
  });
1193
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
 
1202
  const label=m.note||(`frame ${fr}`);
1203
  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`;
1204
  const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1205
+ renameBtn.onclick=async()=>{
1206
  const nv=prompt('Nouveau nom du masque :', label);
1207
  if(nv===null) return;
1208
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
 
1211
  }
1212
  };
1213
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1214
+ delMaskBtn.onclick=async()=>{
1215
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1216
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1217
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
 
1222
  });
1223
  box.appendChild(ul);
1224
  loadingInd.style.display='none';
1225
+ if(mode === 'edit'){ loadMultiMasks(); loadAutoRects(); }
1226
+ }
1227
+ function loadMultiMasks(){
1228
+ multiMaskList.innerHTML = '';
1229
+ const curRects = rectMap.get(currentIdx) || [];
1230
+ if(!curRects.length){ multiMaskList.textContent = 'Aucun masque pour cette frame'; return; }
1231
+ const ul = document.createElement('ul'); ul.className='clean';
1232
+ curRects.forEach((r, i) => {
1233
+ const li = document.createElement('li');
1234
+ li.innerHTML = `<span style="background:${r.color};width:10px;height:10px;border-radius:50%"></span> Masque ${i+1}`;
1235
+ const del = document.createElement('span'); del.className='delete-btn'; del.textContent='❌';
1236
+ del.onclick = () => { rects.splice(i,1); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
1237
+ li.appendChild(del);
1238
+ ul.appendChild(li);
1239
+ });
1240
+ multiMaskList.appendChild(ul);
1241
  }
1242
+ // Save mask (multi: enregistre un par un)
1243
  btnSave.onclick = async ()=>{
1244
+ if(rects.length === 0 || !vidName){ alert('Aucune sélection.'); return; }
1245
+ for(const rect of rects){
1246
+ if(!rect.id){ // only save new
1247
+ const defaultName = `frame ${currentIdx+1}`;
1248
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1249
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1250
+ const x=Math.min(rect.x1,rect.x2)/normW;
1251
+ const y=Math.min(rect.y1,rect.y2)/normH;
1252
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1253
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1254
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:rect.color,note:note};
1255
+ addPending(payload);
1256
+ try{
1257
+ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1258
+ if(r.ok){
1259
+ const d = await r.json();
1260
+ rect.id = d.mask.id; rect.note = note;
1261
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1262
+ savePendingList(lst);
1263
+ rectMap.set(currentIdx, [...rects]);
1264
+ await loadMasks(); await renderTimeline(currentIdx);
1265
+ showToast('Masque enregistré ✅');
1266
+ localStorage.removeItem(AUTOSAVE_KEY); // clear auto-save after save
1267
+ } else {
1268
+ const txt = await r.text();
1269
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1270
+ }
1271
+ }catch(e){
1272
+ alert('Erreur réseau lors de l’enregistrement du masque.');
1273
+ }
1274
  }
 
 
1275
  }
1276
  };
1277
+ // Warm-up UI
1278
+ warmupBtn.onclick = async () => {
1279
+ const r = await fetch('/warmup', {method:'POST'});
1280
+ if(r.ok){
1281
+ showToast('Warm-up lancé');
1282
+ const interval = setInterval(async () => {
1283
+ const pr = await fetch('/warmup_progress');
1284
+ const d = await pr.json();
1285
+ iaProgressFill.style.width = d.percent + '%';
1286
+ iaLogs.innerHTML = d.logs.join('<br>');
1287
+ if(d.done){ clearInterval(interval); }
1288
+ }, 1000);
1289
+ } else {
1290
+ showToast('Warm-up déjà en cours ou erreur');
1291
+ }
1292
+ };
1293
+ // IA Preview (stub)
1294
+ iaPreviewBtn.onclick = () => { alert('En développement, switch GPU'); };
1295
+ // IA Progress poll (exemple, à activer sur /inpaint)
1296
+ const iaPollInterval = setInterval(async () => {
1297
+ if(vidName){
1298
+ const r = await fetch('/progress_ia?vid=' + encodeURIComponent(vidName));
1299
+ const d = await r.json();
1300
+ iaProgressFill.style.width = d.percent + '%';
1301
+ iaLogs.textContent = d.log;
1302
+ }
1303
+ }, 2000);
1304
+ // Boot (ajouts flushPending, tutorial show if first)
1305
  async function boot(){
1306
+ await flushPending();
1307
  await loadFiles();
1308
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1309
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1310
+ if(!localStorage.getItem('tutorial_seen')){ tutorial.classList.remove('hidden'); localStorage.setItem('tutorial_seen', '1'); }
1311
  }
1312
  boot();
 
1313
  const style = document.createElement('style');
1314
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1315
  document.head.appendChild(style);