FABLESLIP commited on
Commit
b68c304
·
verified ·
1 Parent(s): baba197

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1278 -73
app.py CHANGED
@@ -1,14 +1,10 @@
1
- # app.py — Video Editor API (v0.8.2)
2
- # Fixes:
3
- # - Portion rendering shows full selected range (e.g., 1→55, 70→480, 8→88)
4
- # - Accurate centering for "Aller à #" (no offset drift)
5
- # - Robust Warm-up (Hub) with safe imports + error surfacing (no silent crash)
6
- # Base: compatible with previous v0.5.9 routes and UI
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
8
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
9
  from fastapi.staticfiles import StaticFiles
10
  from pathlib import Path
11
- from typing import Optional, Dict, Any
12
  import uuid, shutil, cv2, json, time, urllib.parse, sys
13
  import threading
14
  import subprocess
@@ -17,40 +13,311 @@ import os
17
  import httpx
18
  import huggingface_hub as hf
19
  from joblib import Parallel, delayed
20
- # --- POINTEUR (inchangé) ---
21
- # ... (code pointeur)
22
- print("[BOOT] Starting...")
23
- app = FastAPI(title="Video Editor API", version="0.8.2")
24
- # ... (DATA_DIR, mounts inchangés)
25
- # --- Chargement Modèles au Boot ---
26
- def load_model(repo_id):
27
- path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
28
- if not path.exists() or not any(path.iterdir()):
29
- print(f"[BOOT] Downloading {repo_id}...")
30
- hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
31
- (path / "loaded.ok").touch()
32
- # Symlink exemples
33
- if "sam2" in repo_id: shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
34
- # Ajoute pour autres
35
- models = [
36
- "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
37
- "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ]
39
- Parallel(n_jobs=4)(delayed(load_model)(m) for m in models)
40
- # ProPainter wget
41
- PROP = Path("/app/propainter")
42
- PROP.mkdir(exist_ok=True)
43
- def wget(url, dest):
44
- if not (dest / url.split("/")[-1]).exists():
45
- subprocess.run(["wget", "-q", url, "-P", str(dest)])
46
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth", PROP)
47
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth", PROP)
48
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth", PROP)
49
- print("[BOOT] Models ready.")
50
- # --- PROXY (inchangé) ---
51
- # ... (code proxy)
52
- # Helpers + is_gpu() (inchangé)
53
- # API (Ajouts IA stubs + Nouveaux pour Améliorations)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @app.post("/mask/ai")
55
  async def mask_ai(payload: Dict[str, Any] = Body(...)):
56
  if not is_gpu(): raise HTTPException(503, "Switch GPU.")
@@ -70,19 +337,914 @@ def progress_ia(vid: str):
70
  # TODO: Retourne % et logs (e.g., {"percent": 50, "log": "Frame 25/50"})
71
  return {"percent": 0, "log": "En cours..."}
72
  # ... (autres routes inchangées, étend /mask pour multi-masques array)
73
- # UI (Ajoute undo/redo boutons, preview popup, tutoriel , auto-save JS, feedback barre)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  HTML_TEMPLATE = r"""
75
- Video Editor
76
- # ... (topbar, layout inchangés)
77
- # ... (modes, boutons inchangés)
78
- ↩️ Undo
79
- ↪️ Redo
80
- # ... (palette, masques liste pour multi)
81
- Progression IA:
82
- Tutoriel (cliquer pour masquer)
83
- 1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  """
85
- # ... (ui func inchangée)
86
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
87
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
88
  vid = v or ""
@@ -259,7 +1421,7 @@ HTML_TEMPLATE = r"""
259
  <html lang="fr"><meta charset="utf-8">
260
  <title>Video Editor</title>
261
  <style>
262
- :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb}
263
  *{box-sizing:border-box}
264
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
265
  h1{margin:0 0 8px 0}
@@ -308,17 +1470,33 @@ HTML_TEMPLATE = r"""
308
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
309
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
310
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
 
 
 
 
 
 
 
311
  </style>
312
  <h1>🎬 Video Editor</h1>
313
  <div class="topbar card">
314
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
315
- <strong>Charger une vidéo :</strong>
316
- <input type="file" name="file" accept="video/*" required>
317
- <button class="btn" type="submit">Uploader</button>
318
  </form>
319
  <span class="muted" id="msg">__MSG__</span>
320
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
321
  </div>
 
 
 
 
 
 
 
 
 
322
  <div class="layout">
323
  <div>
324
  <div class="viewer card" id="viewerCard">
@@ -563,40 +1741,62 @@ function setMode(m){
563
  modeLabel.textContent='Édition';
564
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
565
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
 
566
  canvas.style.pointerEvents='auto';
567
- rect = rectMap.get(currentIdx) || null; draw();
568
  }else{
569
  player.controls = true;
570
  playerWrap.classList.remove('edit-mode');
571
  modeLabel.textContent='Lecture';
572
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
573
  btnSave.style.display='none'; btnClear.style.display='none';
 
574
  canvas.style.pointerEvents='none';
575
- rect=null; draw();
576
  }
577
  }
578
  function draw(){
579
  ctx.clearRect(0,0,canvas.width,canvas.height);
580
- if(rect){
581
- const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
582
- const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
583
- ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
584
- ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
585
- }
586
  }
587
  canvas.addEventListener('mousedown',(e)=>{
588
  if(mode!=='edit' || !vidName) return;
589
  dragging=true; const r=canvas.getBoundingClientRect();
590
  sx=e.clientX-r.left; sy=e.clientY-r.top;
591
- rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
592
  });
593
  canvas.addEventListener('mousemove',(e)=>{
594
  if(!dragging) return;
595
  const r=canvas.getBoundingClientRect();
596
- rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
597
  });
598
- ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
599
- btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  btnEdit.onclick =()=> setMode('edit');
601
  btnBack.onclick =()=> setMode('view');
602
  // Palette
@@ -605,7 +1805,7 @@ palette.querySelectorAll('.swatch').forEach(el=>{
605
  el.onclick=()=>{
606
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
607
  el.classList.add('sel'); color=el.dataset.c;
608
- if(rect){ rect.color=color; draw(); }
609
  };
610
  });
611
  // === Timeline ===
@@ -630,7 +1830,12 @@ async function renderTimeline(centerIdx){
630
  }
631
  if(portionStart!=null && portionEnd!=null){
632
  const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
633
- for(let i=s;i<e;i++){ addThumb(i,'append'); }
 
 
 
 
 
634
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
635
  return;
636
  }
@@ -641,8 +1846,8 @@ async function loadWindow(centerIdx){
641
  tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
642
  const rngStart = (viewRangeStart ?? 0);
643
  const rngEnd = (viewRangeEnd ?? frames);
644
- const mid = Math.max(rngStart, Math.min(centerIdx, max(rngStart, rngEnd-1)));
645
- const start = Math.max(rngStart, min(mid - Math.floor(chunkSize/2), max(rngStart, rngEnd - chunkSize)));
646
  const end = Math.min(rngEnd, start + chunkSize);
647
  for(let i=start;i<end;i++){ addThumb(i,'append'); }
648
  timelineStart = start; timelineEnd = end;
@@ -673,14 +1878,14 @@ function addThumb(idx, place='append'){
673
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
674
  img.onclick=async ()=>{
675
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
676
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
677
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
678
  };
679
  wrap.appendChild(img);
680
  const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
681
  wrap.appendChild(label);
682
  if(place==='append'){ tlBox.appendChild(wrap); }
683
- else if(place='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
684
  else{ tlBox.appendChild(wrap); }
685
  thumbEls.set(idx, wrap);
686
  }
 
1
+ # app.py — Video Editor API (v0.8.3)
2
+ # v0.8.3: Fixes multi-rectangles (draw/save multiple per frame, list with delete), portion full range load (batch for large), warm-up resume/retry/UI (logs persist, clear/resume buttons, skip done, error guidance)
 
 
 
 
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
 
13
  import httpx
14
  import huggingface_hub as hf
15
  from joblib import Parallel, delayed
16
+ # --- POINTEUR DE BACKEND (unchanged) ----
17
+ POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
18
+ FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
19
+ _backend_url_cache = {"url": None, "ts": 0.0}
20
+ def get_backend_base() -> str:
21
+ try:
22
+ if POINTER_URL:
23
+ now = time.time()
24
+ need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
25
+ if need_refresh:
26
+ r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
27
+ url = (r.text or "").strip()
28
+ if url.startswith("http"):
29
+ _backend_url_cache["url"] = url
30
+ _backend_url_cache["ts"] = now
31
+ else:
32
+ return FALLBACK_BASE
33
+ return _backend_url_cache["url"] or FALLBACK_BASE
34
+ return FALLBACK_BASE
35
+ except Exception:
36
+ return FALLBACK_BASE
37
+ print("[BOOT] Video Editor API starting…")
38
+ print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
39
+ print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
40
+ app = FastAPI(title="Video Editor API", version="0.8.3")
41
+ DATA_DIR = Path("/app/data")
42
+ THUMB_DIR = DATA_DIR / "_thumbs"
43
+ MASK_DIR = DATA_DIR / "_masks"
44
+ for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
45
+ p.mkdir(parents=True, exist_ok=True)
46
+ app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
47
+ app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
48
+ # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) --------------------
49
+ @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
50
+ async def proxy_all(full_path: str, request: Request):
51
+ base = get_backend_base().rstrip("/")
52
+ target = f"{base}/{full_path}"
53
+ qs = request.url.query
54
+ if qs:
55
+ target = f"{target}?{qs}"
56
+ body = await request.body()
57
+ headers = dict(request.headers)
58
+ headers.pop("host", None)
59
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
60
+ r = await client.request(request.method, target, headers=headers, content=body)
61
+ drop = {"content-encoding","transfer-encoding","connection",
62
+ "keep-alive","proxy-authenticate","proxy-authorization",
63
+ "te","trailers","upgrade"}
64
+ out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
65
+ return Response(content=r.content, status_code=r.status_code, headers=out_headers)
66
+ # --- Global progress dict (vid_stem -> {percent, logs, done}) ----------------
67
+ progress_data: Dict[str, Dict[str, Any]] = {}
68
+ # ---------- Helpers ----------------------------------------------------------
69
+ def _is_video(p: Path) -> bool:
70
+ return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
71
+ def _safe_name(name: str) -> str:
72
+ return Path(name).name.replace(" ", "_")
73
+ def _has_ffmpeg() -> bool:
74
+ return _shutil.which("ffmpeg") is not None
75
+ def _ffmpeg_scale_filter(max_w: int = 320) -> str:
76
+ return f"scale=min(iw\\,{max_w}):-2"
77
+ def _meta(video: Path):
78
+ cap = cv2.VideoCapture(str(video))
79
+ if not cap.isOpened():
80
+ print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
81
+ return None
82
+ frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
83
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
84
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
85
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
86
+ cap.release()
87
+ print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
88
+ return {"frames": frames, "fps": fps, "w": w, "h": h}
89
+ def _frame_jpg(video: Path, idx: int) -> Path:
90
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
91
+ if out.exists():
92
+ return out
93
+ if _has_ffmpeg():
94
+ m = _meta(video) or {"fps": 30.0}
95
+ fps = float(m.get("fps") or 30.0) or 30.0
96
+ t = max(0.0, float(idx) / fps)
97
+ cmd = [
98
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
99
+ "-ss", f"{t:.6f}",
100
+ "-i", str(video),
101
+ "-frames:v", "1",
102
+ "-vf", _ffmpeg_scale_filter(320),
103
+ "-q:v", "8",
104
+ str(out)
105
+ ]
106
+ try:
107
+ subprocess.run(cmd, check=True)
108
+ return out
109
+ except subprocess.CalledProcessError as e:
110
+ print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
111
+ cap = cv2.VideoCapture(str(video))
112
+ if not cap.isOpened():
113
+ print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
114
+ raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
115
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
116
+ if total <= 0:
117
+ cap.release()
118
+ print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
119
+ raise HTTPException(500, "Frame count invalide.")
120
+ idx = max(0, min(idx, total - 1))
121
+ cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
122
+ ok, img = cap.read()
123
+ cap.release()
124
+ if not ok or img is None:
125
+ print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
126
+ raise HTTPException(500, "Impossible de lire la frame demandée.")
127
+ h, w = img.shape[:2]
128
+ if w > 320:
129
+ new_w = 320
130
+ new_h = int(h * (320.0 / w)) or 1
131
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
132
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
133
+ return out
134
+ def _poster(video: Path) -> Path:
135
+ out = THUMB_DIR / f"poster_{video.stem}.jpg"
136
+ if out.exists():
137
+ return out
138
+ try:
139
+ cap = cv2.VideoCapture(str(video))
140
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
141
+ ok, img = cap.read()
142
+ cap.release()
143
+ if ok and img is not None:
144
+ cv2.imwrite(str(out), img)
145
+ except Exception as e:
146
+ print(f"[POSTER] Failed: {e}", file=sys.stdout)
147
+ return out
148
+ def _mask_file(vid: str) -> Path:
149
+ return MASK_DIR / f"{Path(vid).name}.json"
150
+ def _load_masks(vid: str) -> Dict[str, Any]:
151
+ f = _mask_file(vid)
152
+ if f.exists():
153
+ try:
154
+ return json.loads(f.read_text(encoding="utf-8"))
155
+ except Exception as e:
156
+ print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
157
+ return {"video": vid, "masks": []}
158
+ def _save_masks(vid: str, data: Dict[str, Any]):
159
+ _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
160
+ def _gen_thumbs_background(video: Path, vid_stem: str):
161
+ progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
162
+ try:
163
+ m = _meta(video)
164
+ if not m:
165
+ progress_data[vid_stem]['logs'].append("Erreur métadonnées")
166
+ progress_data[vid_stem]['done'] = True
167
+ return
168
+ total_frames = int(m["frames"] or 0)
169
+ if total_frames <= 0:
170
+ progress_data[vid_stem]['logs'].append("Aucune frame détectée")
171
+ progress_data[vid_stem]['done'] = True
172
+ return
173
+ for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
174
+ f.unlink(missing_ok=True)
175
+ if _has_ffmpeg():
176
+ out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
177
+ cmd = [
178
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
179
+ "-i", str(video),
180
+ "-vf", _ffmpeg_scale_filter(320),
181
+ "-q:v", "8",
182
+ "-start_number", "0",
183
+ out_tpl
184
+ ]
185
+ progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
186
+ proc = subprocess.Popen(cmd)
187
+ last_report = -1
188
+ while proc.poll() is None:
189
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
190
+ percent = int(min(99, (generated / max(1, total_frames)) * 100))
191
+ progress_data[vid_stem]['percent'] = percent
192
+ if generated != last_report and generated % 50 == 0:
193
+ progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
194
+ last_report = generated
195
+ time.sleep(0.4)
196
+ proc.wait()
197
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
198
+ progress_data[vid_stem]['percent'] = 100
199
+ progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
200
+ progress_data[vid_stem]['done'] = True
201
+ print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
202
+ else:
203
+ progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
204
+ cap = cv2.VideoCapture(str(video))
205
+ if not cap.isOpened():
206
+ progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
207
+ progress_data[vid_stem]['done'] = True
208
+ return
209
+ idx = 0
210
+ last_report = -1
211
+ while True:
212
+ ok, img = cap.read()
213
+ if not ok or img is None:
214
+ break
215
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
216
+ h, w = img.shape[:2]
217
+ if w > 320:
218
+ new_w = 320
219
+ new_h = int(h * (320.0 / w)) or 1
220
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
221
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
222
+ idx += 1
223
+ if idx % 50 == 0:
224
+ progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
225
+ if idx != last_report:
226
+ progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
227
+ last_report = idx
228
+ cap.release()
229
+ progress_data[vid_stem]['percent'] = 100
230
+ progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
231
+ progress_data[vid_stem]['done'] = True
232
+ print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
233
+ except Exception as e:
234
+ progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
235
+ progress_data[vid_stem]['done'] = True
236
+ def is_gpu():
237
+ return False
238
+ # --- WARM-UP (with resume, retries, persist logs) ---
239
+ warmup_state: Dict[str, Any] = {
240
+ "state": "idle", # idle|running|done|error
241
+ "running": False,
242
+ "percent": 0,
243
+ "current": "",
244
+ "idx": 0,
245
+ "total": 0,
246
+ "log": [],
247
+ "started_at": None,
248
+ "finished_at": None,
249
+ "last_error": "",
250
+ "done_models": [] # list of completed repo_ids
251
+ }
252
+ WARMUP_MODELS: List[str] = [
253
+ "facebook/sam2-hiera-large",
254
+ "lixiaowen/diffuEraser",
255
+ "runwayml/stable-diffusion-v1-5",
256
+ "stabilityai/sd-vae-ft-mse",
257
+ "ByteDance/Sa2VA-4B",
258
+ "wangfuyun/PCM_Weights",
259
  ]
260
+ def _append_warmup_log(msg: str):
261
+ warmup_state["log"].append(msg)
262
+ if len(warmup_state["log"]) > 200:
263
+ warmup_state["log"] = warmup_state["log"][-200:]
264
+ def _do_warmup(resume=False):
265
+ warmup_state["running"] = True
266
+ warmup_state["state"] = "running"
267
+ warmup_state["started_at"] = time.time()
268
+ warmup_state["finished_at"] = None
269
+ warmup_state["last_error"] = ""
270
+ if not resume:
271
+ warmup_state["done_models"] = []
272
+ warmup_state["log"] = ["Warm-up started."]
273
+ warmup_state["total"] = len(WARMUP_MODELS)
274
+ warmup_state["idx"] = len(warmup_state["done_models"])
275
+ token = os.getenv("HF_TOKEN", None)
276
+ try:
277
+ for repo in WARMUP_MODELS[warmup_state["idx"]:]:
278
+ warmup_state["current"] = repo
279
+ warmup_state["percent"] = int((warmup_state["idx"] / warmup_state["total"]) * 100)
280
+ _append_warmup_log(f"➡️ Téléchargement: {repo}")
281
+ if load_model(repo):
282
+ warmup_state["done_models"].append(repo)
283
+ warmup_state["idx"] += 1
284
+ warmup_state["percent"] = int((warmup_state["idx"] / warmup_state["total"]) * 100)
285
+ _append_warmup_log(f"✅ OK: {repo}")
286
+ else:
287
+ warmup_state["state"] = "error"
288
+ warmup_state["last_error"] = f"Failed {repo} after retries."
289
+ _append_warmup_log(f"⚠️ Failed {repo} after retries.")
290
+ break
291
+ if warmup_state["state"] != "error":
292
+ warmup_state["state"] = "done"
293
+ warmup_state["percent"] = 100
294
+ _append_warmup_log("Warm-up complete.")
295
+ except Exception as e:
296
+ warmup_state["state"] = "error"
297
+ warmup_state["last_error"] = str(e)
298
+ _append_warmup_log(f"❌ Warm-up erreur globale: {e}")
299
+ warmup_state["running"] = False
300
+ warmup_state["finished_at"] = time.time()
301
+ @app.post("/warmup/start", tags=["warmup"])
302
+ def warmup_start():
303
+ if warmup_state["running"]:
304
+ return {"ok": False, "detail": "already running", "state": warmup_state}
305
+ threading.Thread(target=_do_warmup, daemon=True).start()
306
+ return {"ok": True, "state": warmup_state}
307
+ @app.post("/warmup/resume", tags=["warmup"])
308
+ def warmup_resume():
309
+ if warmup_state["running"]:
310
+ return {"ok": False, "detail": "already running", "state": warmup_state}
311
+ threading.Thread(target=_do_warmup, args=(True,), daemon=True).start()
312
+ return {"ok": True, "state": warmup_state}
313
+ @app.post("/warmup/clear_logs", tags=["warmup"])
314
+ def warmup_clear_logs():
315
+ warmup_state["log"] = []
316
+ return {"ok": True}
317
+ @app.get("/warmup/status", tags=["warmup"])
318
+ def warmup_status():
319
+ return warmup_state
320
+ # --- API (Ajouts IA stubs + Nouveaux pour Améliorations) ----------------------
321
  @app.post("/mask/ai")
322
  async def mask_ai(payload: Dict[str, Any] = Body(...)):
323
  if not is_gpu(): raise HTTPException(503, "Switch GPU.")
 
337
  # TODO: Retourne % et logs (e.g., {"percent": 50, "log": "Frame 25/50"})
338
  return {"percent": 0, "log": "En cours..."}
339
  # ... (autres routes inchangées, étend /mask pour multi-masques array)
340
+ # ----- Masques (modified for multi per frame) --------------------------------
341
+ @app.post("/mask", tags=["mask"])
342
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
343
+ vid = payload.get("vid")
344
+ if not vid:
345
+ raise HTTPException(400, "vid manquant")
346
+ pts = payload.get("points") or []
347
+ if len(pts) != 4:
348
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
349
+ data = _load_masks(vid)
350
+ m = {
351
+ "id": uuid.uuid4().hex[:10],
352
+ "time_s": float(payload.get("time_s") or 0.0),
353
+ "frame_idx": int(payload.get("frame_idx") or 0),
354
+ "shape": "rect",
355
+ "points": [float(x) for x in pts],
356
+ "color": payload.get("color") or "#10b981",
357
+ "note": payload.get("note") or ""
358
+ }
359
+ data.setdefault("masks", []).append(m)
360
+ _save_masks(vid, data)
361
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
362
+ return {"saved": True, "mask": m}
363
+ @app.get("/mask/{vid}", tags=["mask"])
364
+ def list_masks(vid: str):
365
+ return _load_masks(vid)
366
+ @app.post("/mask/rename", tags=["mask"])
367
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
368
+ vid = payload.get("vid")
369
+ mid = payload.get("id")
370
+ new_note = (payload.get("note") or "").strip()
371
+ if not vid or not mid:
372
+ raise HTTPException(400, "vid et id requis")
373
+ data = _load_masks(vid)
374
+ for m in data.get("masks", []):
375
+ if m.get("id") == mid:
376
+ m["note"] = new_note
377
+ _save_masks(vid, data)
378
+ return {"ok": True}
379
+ raise HTTPException(404, "Masque introuvable")
380
+ @app.post("/mask/delete", tags=["mask"])
381
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
382
+ vid = payload.get("vid")
383
+ mid = payload.get("id")
384
+ if not vid or not mid:
385
+ raise HTTPException(400, "vid et id requis")
386
+ data = _load_masks(vid)
387
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
388
+ _save_masks(vid, data)
389
+ return {"ok": True}
390
+ # --- UI (added warmup resume/clear, multi-mask list, undo/redo, preview stub, estimation, tutoriel, auto-save, ia progress) ---
391
  HTML_TEMPLATE = r"""
392
+ <!doctype html>
393
+ <html lang="fr"><meta charset="utf-8">
394
+ <title>Video Editor</title>
395
+ <style>
396
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
397
+ *{box-sizing:border-box}
398
+ body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
399
+ h1{margin:0 0 8px 0}
400
+ .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
401
+ .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
402
+ .muted{color:var(--muted);font-size:13px}
403
+ .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
404
+ .viewer{max-width:1024px;margin:0 auto; position:relative}
405
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
406
+ video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
407
+ #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
408
+ .timeline-container{margin-top:10px}
409
+ .timeline{position:relative;display:flex;flex-wrap:nowrap;gap:8px;overflow-x:auto;overflow-y:hidden;padding:6px;border:1px solid var(--b);border-radius:10px;-webkit-overflow-scrolling:touch;width:100%}
410
+ .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
411
+ .thumb:hover{transform:scale(1.05)}
412
+ .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
413
+ .thumb img.sel{border-color:var(--active-border)}
414
+ .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
415
+ .thumb.hasmask::after{content:"★";position:absolute;right:6px;top:4px;color:#f5b700;text-shadow:0 0 3px rgba(0,0,0,0.35);font-size:16px}
416
+ .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
417
+ .timeline.filter-masked .thumb:not(.hasmask){display:none}
418
+ .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
419
+ .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
420
+ .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
421
+ .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
422
+ .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}
423
+ .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
424
+ ul.clean{list-style:none;padding-left:0;margin:6px 0}
425
+ ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
426
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
427
+ .rename-btn:hover{color:#2563eb}
428
+ .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
429
+ .delete-btn:hover{color:#b91c1c}
430
+ #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
431
+ .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
432
+ .portion-input{width:70px}
433
+ #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
434
+ #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
435
+ #popup {position:fixed;top:20%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:20px;border-radius:8px;box-shadow:0 0 10px rgba(0,0,0,0.2);z-index:1000;display:none;min-width:320px}
436
+ #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
437
+ #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
438
+ #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
439
+ .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
440
+ #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
441
+ #inHandle,#outHandle{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);width:6px;background:rgba(37,99,235,.9);border-radius:2px;cursor:ew-resize;display:none;pointer-events:auto}
442
+ #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
443
+ #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
444
+ .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
445
+ #warmupBox{display:flex;align-items:center;gap:8px;margin-bottom:8px}
446
+ #warmup-progress{background:#f3f4f6;border-radius:4px;height:8px;flex:1}
447
+ #warmup-progress div{background:#10b981;height:100%;width:0;border-radius:4px}
448
+ #warmup-resume{margin-left:8px}
449
+ #warmup-clear{margin-left:8px}
450
+ #warmup-logs{max-height:120px;overflow:auto;font-size:12px;color:#6b7280}
451
+ #multiMaskList{max-height:150px;overflow:auto;margin-top:8px}
452
+ #tutorial {background:#fef9c3;padding:10px;border-radius:8px;margin-top:12px;display:block}
453
+ #tutorial.hidden {display:none}
454
+ #iaProgress {background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
455
+ #iaProgress div {background:#2563eb;height:100%;width:0;border-radius:4px}
456
+ #iaLogs {max-height:100px;overflow:auto;font-size:12px;color:#6b7280;margin-top:4px}
457
+ .err {color:#ef4444}
458
+ </style>
459
+ <h1>🎬 Video Editor</h1>
460
+ <div class="topbar card">
461
+ <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
462
+ <strong>Charger une vidéo :</strong>
463
+ <input type="file" name="file" accept="video/*" required>
464
+ <button class="btn" type="submit">Uploader</button>
465
+ </form>
466
+ <span class="muted" id="msg">__MSG__</span>
467
+ <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
468
+ </div>
469
+ <div class="card" style="margin-bottom:10px">
470
+ <div id="warmupBox">
471
+ <button id="warmupBtn" class="btn">⚡ Warm-up modèles</button>
472
+ <button id="warmup-resume" class="btn" style="display:none">Reprendre</button>
473
+ <button id="warmup-clear" class="btn" style="display:none">Effacer logs</button>
474
+ <div id="warmup-progress"><div></div></div>
475
+ </div>
476
+ <div id="warmup-logs" class="muted"></div>
477
+ </div>
478
+ <div class="layout">
479
+ <div>
480
+ <div class="viewer card" id="viewerCard">
481
+ <div class="player-wrap" id="playerWrap">
482
+ <video id="player" controls playsinline poster="/poster/__VID__">
483
+ <source id="vidsrc" src="/data/__VID__" type="video/mp4">
484
+ </video>
485
+ <canvas id="editCanvas"></canvas>
486
+ <div id="hud"></div>
487
+ </div>
488
+ <div class="muted" style="margin-top:6px">
489
+ <label>Frame # <input id="goFrame" type="number" min="1" value="1" style="width:90px"> </label>
490
+ <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
491
+ <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
492
+ <button id="resetFull" class="btn" style="display:none">Retour full</button>
493
+ <span id="posInfo" style="margin-left:10px"></span>
494
+ <span id="status" style="margin-left:10px;color:#2563eb"></span>
495
+ </div>
496
+ </div>
497
+ <div class="card timeline-container">
498
+ <h4 style="margin:2px 0 8px 0">Timeline</h4>
499
+ <div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center">
500
+ <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
501
+ <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
502
+ <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
503
+ <label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
504
+ <button id="gotoBtn" class="btn">Aller</button>
505
+ <span class="muted" id="maskedCount"></span>
506
+ </div>
507
+ <div id="timeline" class="timeline"></div>
508
+ <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
509
+ <div id="loading-indicator">Chargement des frames...</div>
510
+ <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
511
+ </div>
512
+ </div>
513
+ <div class="card tools">
514
+ <div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
515
+ <div class="row" style="margin-top:6px">
516
+ <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
517
+ <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
518
+ <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
519
+ <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
520
+ <button id="undoBtn" class="btn" style="display:none">↩️ Undo</button>
521
+ <button id="redoBtn" class="btn" style="display:none">↪️ Redo</button>
522
+ </div>
523
+ <div style="margin-top:10px">
524
+ <div class="muted">Couleur</div>
525
+ <div class="row" id="palette" style="margin-top:6px">
526
+ <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
527
+ <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
528
+ <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
529
+ <div class="swatch" data-c="#f59e0b" style="background:#f59e0b" title="Orange"></div>
530
+ <div class="swatch" data-c="#a21caf" style="background:#a21caf" title="Violet"></div>
531
+ </div>
532
+ </div>
533
+ <div style="margin-top:12px">
534
+ <details open>
535
+ <summary><strong>Masques</strong></summary>
536
+ <div id="maskList" class="muted">—</div>
537
+ <div id="multiMaskList" class="muted">—</div>
538
+ <button class="btn" style="margin-top:8px" id="iaPreviewBtn">🔍 Preview IA</button>
539
+ <button class="btn" style="margin-top:8px" id="estimateBtn">⏱️ Estimer</button>
540
+ <div id="iaProgress"><div></div></div>
541
+ <div id="iaLogs"></div>
542
+ <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>
543
+ </details>
544
+ <div style="margin-top:6px">
545
+ <strong>Vidéos disponibles</strong>
546
+ <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
547
+ </div>
548
+ </div>
549
+ <div id="tutorial">
550
+ <h4>Tutoriel</h4>
551
+ <p>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</p>
552
+ <button onclick="this.parentElement.classList.add('hidden')">Masquer</button>
553
+ </div>
554
+ </div>
555
+ </div>
556
+ <div id="popup">
557
+ <h3>Génération thumbs en cours</h3>
558
+ <div id="popup-progress-bar"><div id="popup-progress-fill"></div></div>
559
+ <div id="popup-logs"></div>
560
+ </div>
561
+ <div id="toast"></div>
562
+ <script>
563
+ const serverVid = "__VID__";
564
+ const serverMsg = "__MSG__";
565
+ document.getElementById('msg').textContent = serverMsg;
566
+ // Elements
567
+ const statusEl = document.getElementById('status');
568
+ const player = document.getElementById('player');
569
+ const srcEl = document.getElementById('vidsrc');
570
+ const canvas = document.getElementById('editCanvas');
571
+ const ctx = canvas.getContext('2d');
572
+ const modeLabel = document.getElementById('modeLabel');
573
+ const btnEdit = document.getElementById('btnEdit');
574
+ const btnBack = document.getElementById('btnBack');
575
+ const btnSave = document.getElementById('btnSave');
576
+ const btnClear= document.getElementById('btnClear');
577
+ const posInfo = document.getElementById('posInfo');
578
+ const goFrame = document.getElementById('goFrame');
579
+ const palette = document.getElementById('palette');
580
+ const fileList= document.getElementById('fileList');
581
+ const tlBox = document.getElementById('timeline');
582
+ const tlNote = document.getElementById('tlNote');
583
+ const playerWrap = document.getElementById('playerWrap');
584
+ const loadingInd = document.getElementById('loading-indicator');
585
+ const isolerBoucle = document.getElementById('isolerBoucle');
586
+ const resetFull = document.getElementById('resetFull');
587
+ const endPortion = document.getElementById('endPortion');
588
+ const popup = document.getElementById('popup');
589
+ const popupLogs = document.getElementById('popup-logs');
590
+ const tlProgressFill = document.getElementById('tl-progress-fill');
591
+ const popupProgressFill = document.getElementById('popup-progress-fill');
592
+ const btnFollow = document.getElementById('btnFollow');
593
+ const btnFilterMasked = document.getElementById('btnFilterMasked');
594
+ const zoomSlider = document.getElementById('zoomSlider');
595
+ const maskedCount = document.getElementById('maskedCount');
596
+ const hud = document.getElementById('hud');
597
+ const toastWrap = document.getElementById('toast');
598
+ const gotoInput = document.getElementById('gotoInput');
599
+ const gotoBtn = document.getElementById('gotoBtn');
600
+ const tutorial = document.getElementById('tutorial');
601
+ const warmupBtn = document.getElementById('warmupBtn');
602
+ const warmupResume = document.getElementById('warmup-resume');
603
+ const warmupClear = document.getElementById('warmup-clear');
604
+ const warmupProgress = document.getElementById('warmup-progress').firstChild;
605
+ const warmupLogs = document.getElementById('warmup-logs');
606
+ const iaPreviewBtn = document.getElementById('iaPreviewBtn');
607
+ const estimateBtn = document.getElementById('estimateBtn');
608
+ const iaProgress = document.getElementById('iaProgress').firstChild;
609
+ const iaLogs = document.getElementById('iaLogs');
610
+ const multiMaskList = document.getElementById('multiMaskList');
611
+ const undoBtn = document.getElementById('undoBtn');
612
+ const redoBtn = document.getElementById('redoBtn');
613
+ // State
614
+ let vidName = serverVid || '';
615
+ function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
616
+ let vidStem = '';
617
+ let bustToken = Date.now();
618
+ let fps = 30, frames = 0;
619
+ let currentIdx = 0;
620
+ let mode = 'view';
621
+ let rects = []; // list for multi
622
+ let dragging = false, sx = 0, sy = 0, currentRect = null;
623
+ let color = '#10b981';
624
+ let rectMap = new Map(); // idx -> list of rects
625
+ let history = []; // undo/redo stack
626
+ let historyIdx = -1;
627
+ let masks = [];
628
+ let maskedSet = new Set();
629
+ let timelineUrls = [];
630
+ let portionStart = null;
631
+ let portionEnd = null;
632
+ let loopInterval = null;
633
+ let chunkSize = 50;
634
+ let timelineStart = 0, timelineEnd = 0;
635
+ let viewRangeStart = 0, viewRangeEnd = 0; // portée visible (portion ou full)
636
+ const scrollThreshold = 100;
637
+ let followMode = false;
638
+ let isPaused = true;
639
+ let thumbEls = new Map();
640
+ let lastCenterMs = 0;
641
+ const CENTER_THROTTLE_MS = 150;
642
+ const PENDING_KEY = 've_pending_masks_v1';
643
+ let maskedOnlyMode = false;
644
+ const AUTOSAVE_KEY = 've_autosave_rects_v1';
645
+ // Utils
646
+ function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
647
+ function ensureOverlays(){
648
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
649
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
650
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
651
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
652
+ }
653
+ function playheadEl(){ return document.getElementById('playhead'); }
654
+ function portionBand(){ return document.getElementById('portionBand'); }
655
+ function inHandle(){ return document.getElementById('inHandle'); }
656
+ function outHandle(){ return document.getElementById('outHandle'); }
657
+ function findThumbEl(idx){ return thumbEls.get(idx) || null; }
658
+ function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
659
+ function updateSelectedThumb(){
660
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
661
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
662
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
663
+ }
664
+ function rawCenterThumb(el){
665
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
666
+ }
667
+ async function ensureThumbVisibleCentered(idx){
668
+ // Attendre que l’élément + image soient prêts avant de centrer
669
+ for(let k=0; k<40; k++){
670
+ const el = findThumbEl(idx);
671
+ if(el){
672
+ const img = el.querySelector('img');
673
+ if(!img.complete || img.naturalWidth === 0){
674
+ await new Promise(r=>setTimeout(r, 25));
675
+ }else{
676
+ rawCenterThumb(el);
677
+ updatePlayhead();
678
+ return true;
679
+ }
680
+ }else{
681
+ await new Promise(r=>setTimeout(r, 25));
682
+ }
683
+ }
684
+ return false;
685
+ }
686
+ function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
687
+ function updatePlayhead(){
688
+ ensureOverlays();
689
+ const el = findThumbEl(currentIdx);
690
+ const ph = playheadEl();
691
+ if(!el){ ph.style.display='none'; return; }
692
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
693
+ }
694
+ function updatePortionOverlays(){
695
+ ensureOverlays();
696
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
697
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
698
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
699
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
700
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
701
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
702
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
703
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
704
+ }
705
+ function nearestFrameIdxFromClientX(clientX){
706
+ const rect = tlBox.getBoundingClientRect();
707
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
708
+ let bestIdx = currentIdx, bestDist = Infinity;
709
+ for(const [idx, el] of thumbEls.entries()){
710
+ const mid = el.offsetLeft + el.clientWidth/2;
711
+ const d = Math.abs(mid - xIn);
712
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
713
+ }
714
+ return bestIdx;
715
+ }
716
+ function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
717
+ function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
718
+ function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
719
+ async function flushPending(){
720
+ const lst = loadPending(); if(!lst.length) return;
721
+ const kept = [];
722
+ for(const p of lst){
723
+ try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
724
+ catch{ kept.push(p); }
725
+ }
726
+ savePendingList(kept);
727
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
728
+ }
729
+ // Layout
730
+ function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
731
+ function fitCanvas(){
732
+ const r=player.getBoundingClientRect();
733
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
734
+ canvas.width=Math.round(r.width);
735
+ canvas.height=Math.round(r.height - ctrlH);
736
+ canvas.style.width=r.width+'px';
737
+ canvas.style.height=(r.height - ctrlH)+'px';
738
+ syncTimelineWidth();
739
+ }
740
+ function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
741
+ function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
742
+ function setMode(m){
743
+ mode=m;
744
+ if(m==='edit'){
745
+ player.pause();
746
+ player.controls = false;
747
+ playerWrap.classList.add('edit-mode');
748
+ modeLabel.textContent='Édition';
749
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
750
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
751
+ canvas.style.pointerEvents='auto';
752
+ rect = rectMap.get(currentIdx) || null; draw();
753
+ }else{
754
+ player.controls = true;
755
+ playerWrap.classList.remove('edit-mode');
756
+ modeLabel.textContent='Lecture';
757
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
758
+ btnSave.style.display='none'; btnClear.style.display='none';
759
+ canvas.style.pointerEvents='none';
760
+ rect=null; draw();
761
+ }
762
+ }
763
+ function draw(){
764
+ ctx.clearRect(0,0,canvas.width,canvas.height);
765
+ if(rect){
766
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
767
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
768
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
769
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
770
+ }
771
+ }
772
+ canvas.addEventListener('mousedown',(e)=>{
773
+ if(mode!=='edit' || !vidName) return;
774
+ dragging=true; const r=canvas.getBoundingClientRect();
775
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
776
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
777
+ });
778
+ canvas.addEventListener('mousemove',(e)=>{
779
+ if(!dragging) return;
780
+ const r=canvas.getBoundingClientRect();
781
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
782
+ });
783
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
784
+ btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
785
+ btnEdit.onclick =()=> setMode('edit');
786
+ btnBack.onclick =()=> setMode('view');
787
+ // Palette
788
+ palette.querySelectorAll('.swatch').forEach(el=>{
789
+ if(el.dataset.c===color) el.classList.add('sel');
790
+ el.onclick=()=>{
791
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
792
+ el.classList.add('sel'); color=el.dataset.c;
793
+ if(rect){ rect.color=color; draw(); }
794
+ };
795
+ });
796
+ // === Timeline ===
797
+ async function loadTimelineUrls(){
798
+ timelineUrls = [];
799
+ const stem = vidStem, b = bustToken;
800
+ for(let idx=0; idx<frames; idx++){
801
+ timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`;
802
+ }
803
+ tlProgressFill.style.width='0%';
804
+ }
805
+ async function renderTimeline(centerIdx){
806
+ if(!vidName) return;
807
+ loadingInd.style.display='block';
808
+ if(timelineUrls.length===0) await loadTimelineUrls();
809
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
810
+ if(maskedOnlyMode){
811
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
812
+ for(const i of idxs){ addThumb(i,'append'); }
813
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
814
+ return;
815
+ }
816
+ if(portionStart!=null && portionEnd!=null){
817
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
818
+ const batchSize = 200; // load large portions in batches to avoid lag
819
+ for (let start = s; start < e; start += batchSize) {
820
+ const end = Math.min(start + batchSize, e);
821
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
822
+ await new Promise(r=>setTimeout(r, 50)); // breath for UI
823
+ }
824
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
825
+ return;
826
+ }
827
+ await loadWindow(centerIdx ?? currentIdx);
828
+ loadingInd.style.display='none';
829
+ }
830
+ async function loadWindow(centerIdx){
831
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
832
+ const rngStart = (viewRangeStart ?? 0);
833
+ const rngEnd = (viewRangeEnd ?? frames);
834
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
835
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
836
+ const end = Math.min(rngEnd, start + chunkSize);
837
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
838
+ timelineStart = start; timelineEnd = end;
839
+ setTimeout(async ()=>{
840
+ syncTimelineWidth();
841
+ updateSelectedThumb();
842
+ await ensureThumbVisibleCentered(currentIdx);
843
+ updatePortionOverlays();
844
+ },0);
845
+ }
846
+ function addThumb(idx, place='append'){
847
+ if(thumbEls.has(idx)) return;
848
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
849
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
850
+ const img=new Image(); img.title='frame '+(idx+1);
851
+ img.src=timelineUrls[idx];
852
+ img.onerror = () => {
853
+ const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
854
+ img.onerror = null;
855
+ img.src = fallback;
856
+ img.onload = () => {
857
+ const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
858
+ timelineUrls[idx] = nu;
859
+ img.src = nu;
860
+ img.onload = null;
861
+ };
862
+ };
863
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
864
+ img.onclick=async ()=>{
865
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
866
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
867
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
868
+ };
869
+ wrap.appendChild(img);
870
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
871
+ wrap.appendChild(label);
872
+ if(place==='append'){ tlBox.appendChild(wrap); }
873
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
874
+ else{ tlBox.appendChild(wrap); }
875
+ thumbEls.set(idx, wrap);
876
+ }
877
+ // Scroll chunk (mode normal uniquement)
878
+ tlBox.addEventListener('scroll', ()=>{
879
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
880
+ updatePlayhead(); updatePortionOverlays();
881
+ return;
882
+ }
883
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
884
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
885
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
886
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
887
+ timelineEnd = newEnd;
888
+ }
889
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
890
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
891
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
892
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
893
+ timelineStart = newStart;
894
+ }
895
+ updatePlayhead(); updatePortionOverlays();
896
+ });
897
+ // Isoler & Boucle
898
+ isolerBoucle.onclick = async ()=>{
899
+ const start = parseInt(goFrame.value || '1',10) - 1;
900
+ const end = parseInt(endPortion.value || '',10);
901
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
902
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
903
+ portionStart = start; portionEnd = end;
904
+ viewRangeStart = start; viewRangeEnd = end;
905
+ player.pause(); isPaused = true;
906
+ currentIdx = start; player.currentTime = idxToSec(start);
907
+ await renderTimeline(currentIdx);
908
+ resetFull.style.display = 'inline-block';
909
+ startLoop(); updatePortionOverlays();
910
+ };
911
+ function startLoop(){
912
+ if(loopInterval) clearInterval(loopInterval);
913
+ if(portionEnd != null){
914
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
915
+ }
916
+ }
917
+ resetFull.onclick = async ()=>{
918
+ portionStart = null; portionEnd = null;
919
+ viewRangeStart = 0; viewRangeEnd = frames;
920
+ goFrame.value = 1; endPortion.value = '';
921
+ player.pause(); isPaused = true;
922
+ await renderTimeline(currentIdx);
923
+ resetFull.style.display='none';
924
+ clearInterval(loopInterval); updatePortionOverlays();
925
+ };
926
+ // Drag IN/OUT
927
+ function attachHandleDrag(handle, which){
928
+ let draggingH=false;
929
+ function onMove(e){
930
+ if(!draggingH) return;
931
+ const idx = nearestFrameIdxFromClientX(e.clientX);
932
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
933
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
934
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
935
+ updatePortionOverlays();
936
+ }
937
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
938
+ window.addEventListener('mousemove', onMove);
939
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
940
+ }
941
+ ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
942
+ // Progress popup
943
+ async function showProgress(vidStem){
944
+ popup.style.display = 'block';
945
+ const interval = setInterval(async () => {
946
+ const r = await fetch('/progress/' + vidStem);
947
+ const d = await r.json();
948
+ tlProgressFill.style.width = d.percent + '%';
949
+ popupProgressFill.style.width = d.percent + '%';
950
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
951
+ if(d.done){
952
+ clearInterval(interval);
953
+ popup.style.display = 'none';
954
+ await renderTimeline(currentIdx);
955
+ }
956
+ }, 800);
957
+ }
958
+ // Meta & boot
959
+ async function loadVideoAndMeta() {
960
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
961
+ vidStem = fileStem(vidName); bustToken = Date.now();
962
+ const bust = Date.now();
963
+ srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
964
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
965
+ player.load();
966
+ fitCanvas();
967
+ statusEl.textContent = 'Chargement vidéo…';
968
+ try{
969
+ const r=await fetch('/meta/'+encodeURIComponent(vidName));
970
+ if(r.ok){
971
+ const m=await r.json();
972
+ fps=m.fps||30; frames=m.frames||0;
973
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
974
+ viewRangeStart = 0; viewRangeEnd = frames;
975
+ await loadTimelineUrls();
976
+ await loadMasks();
977
+ currentIdx = 0; player.currentTime = 0;
978
+ await renderTimeline(0);
979
+ showProgress(vidStem);
980
+ }else{
981
+ statusEl.textContent = 'Erreur meta';
982
+ }
983
+ }catch(err){
984
+ statusEl.textContent = 'Erreur réseau meta';
985
+ }
986
+ }
987
+ player.addEventListener('loadedmetadata', async ()=>{
988
+ fitCanvas();
989
+ if(!frames || frames<=0){
990
+ 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{}
991
+ }
992
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
993
+ });
994
+ window.addEventListener('resize', ()=>{ fitCanvas(); });
995
+ player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
996
+ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
997
+ player.addEventListener('timeupdate', ()=>{
998
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
999
+ currentIdx=timeToIdx(player.currentTime);
1000
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1001
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
1002
+ if(followMode && !isPaused){
1003
+ const now = Date.now();
1004
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1005
+ }
1006
+ });
1007
+ goFrame.addEventListener('change', async ()=>{
1008
+ if(!vidName) return;
1009
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
1010
+ player.pause(); isPaused = true;
1011
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1012
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1013
+ await renderTimeline(currentIdx);
1014
+ await ensureThumbVisibleCentered(currentIdx);
1015
+ });
1016
+ // Follow / Filter / Zoom / Goto
1017
+ btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1018
+ btnFilterMasked.onclick = async ()=>{
1019
+ maskedOnlyMode = !maskedOnlyMode;
1020
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1021
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1022
+ await renderTimeline(currentIdx);
1023
+ await ensureThumbVisibleCentered(currentIdx);
1024
+ };
1025
+ zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1026
+ async function gotoFrameNum(){
1027
+ const v = parseInt(gotoInput.value||'',10);
1028
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1029
+ player.pause(); isPaused = true;
1030
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1031
+ goFrame.value = v;
1032
+ await renderTimeline(currentIdx);
1033
+ await ensureThumbVisibleCentered(currentIdx);
1034
+ }
1035
+ gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1036
+ gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1037
+ // Drag & drop upload
1038
+ const uploadZone = document.getElementById('uploadForm');
1039
+ uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1040
+ uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1041
+ uploadZone.addEventListener('drop', (e) => {
1042
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1043
+ const file = e.dataTransfer.files[0];
1044
+ if(file && file.type.startsWith('video/')){
1045
+ const fd = new FormData(); fd.append('file', file);
1046
+ fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1047
+ }
1048
+ });
1049
+ // Export placeholder
1050
+ document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1051
+ // Fichiers & masques
1052
+ async function loadFiles(){
1053
+ const r=await fetch('/files'); const d=await r.json();
1054
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1055
+ fileList.innerHTML='';
1056
+ d.items.forEach(name=>{
1057
+ const li=document.createElement('li');
1058
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1059
+ delBtn.onclick=async()=>{
1060
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1061
+ await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
1062
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1063
+ };
1064
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1065
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1066
+ });
1067
+ }
1068
+ async function loadMasks(){
1069
+ loadingInd.style.display='block';
1070
+ const box=document.getElementById('maskList');
1071
+ const r=await fetch('/mask/'+encodeURIComponent(vidName));
1072
+ const d=await r.json();
1073
+ masks=d.masks||[];
1074
+ maskedSet = new Set();
1075
+ rectMap.clear();
1076
+ masks.forEach(m=>{
1077
+ const idx = m.frame_idx;
1078
+ maskedSet.add(idx);
1079
+ if(!rectMap.has(idx)) rectMap.set(idx, []);
1080
+ if(m.shape==='rect'){
1081
+ const [x1,y1,x2,y2] = m.points;
1082
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1083
+ 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});
1084
+ }
1085
+ });
1086
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1087
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1088
+ box.innerHTML='';
1089
+ const ul=document.createElement('ul'); ul.className='clean';
1090
+ masks.forEach(m=>{
1091
+ const li=document.createElement('li');
1092
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1093
+ const t=(m.time_s||0).toFixed(2);
1094
+ const col=m.color||'#10b981';
1095
+ const label=m.note||(`frame ${fr}`);
1096
+ 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`;
1097
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1098
+ renameBtn.onclick=async()=>{
1099
+ const nv=prompt('Nouveau nom du masque :', label);
1100
+ if(nv===null) return;
1101
+ const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1102
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1103
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1104
+ }
1105
+ };
1106
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1107
+ delMaskBtn.onclick=async()=>{
1108
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1109
+ const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1110
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1111
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1112
+ }
1113
+ };
1114
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1115
+ });
1116
+ box.appendChild(ul);
1117
+ loadingInd.style.display='none';
1118
+ if(mode === 'edit'){ loadMultiMasks(); loadAutoRects(); }
1119
+ }
1120
+ function loadMultiMasks(){
1121
+ multiMaskList.innerHTML = '';
1122
+ const curRects = rectMap.get(currentIdx) || [];
1123
+ if(!curRects.length){ multiMaskList.textContent = 'Aucun masque pour cette frame'; return; }
1124
+ const ul = document.createElement('ul'); ul.className='clean';
1125
+ curRects.forEach((r, i) => {
1126
+ const li = document.createElement('li');
1127
+ li.innerHTML = `<span style="background:${r.color};width:10px;height:10px;border-radius:50%"></span> Masque ${i+1}`;
1128
+ const del = document.createElement('span'); del.className='delete-btn'; del.textContent='❌';
1129
+ del.onclick = () => { rects.splice(i,1); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
1130
+ li.appendChild(del);
1131
+ ul.appendChild(li);
1132
+ });
1133
+ multiMaskList.appendChild(ul);
1134
+ }
1135
+ // Save mask (+ cache)
1136
+ btnSave.onclick = async ()=>{
1137
+ if(rects.length === 0 || !vidName){ alert('Aucune sélection.'); return; }
1138
+ for(const rect of rects){
1139
+ if(!rect.id){ // only save new
1140
+ const defaultName = `frame ${currentIdx+1}`;
1141
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1142
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1143
+ const x=Math.min(rect.x1,rect.x2)/normW;
1144
+ const y=Math.min(rect.y1,rect.y2)/normH;
1145
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1146
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1147
+ 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};
1148
+ addPending(payload);
1149
+ try{
1150
+ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1151
+ if(r.ok){
1152
+ const d = await r.json();
1153
+ rect.id = d.mask.id; rect.note = note;
1154
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1155
+ savePendingList(lst);
1156
+ rectMap.set(currentIdx, [...rects]);
1157
+ await loadMasks(); await renderTimeline(currentIdx);
1158
+ showToast('Masque enregistré ✅');
1159
+ localStorage.removeItem(AUTOSAVE_KEY); // clear auto-save after save
1160
+ } else {
1161
+ const txt = await r.text();
1162
+ if (r.status === 500) {
1163
+ alert('Erreur serveur - Vérifiez les logs ou réessayez.');
1164
+ } else {
1165
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1166
+ }
1167
+ }
1168
+ }catch(e){
1169
+ alert('Erreur réseau lors de l’enregistrement du masque.');
1170
+ }
1171
+ }
1172
+ }
1173
+ };
1174
+ // Warm-up UI
1175
+ warmupBtn.onclick = async () => {
1176
+ try {
1177
+ await fetch('/warmup/start', {method: 'POST'});
1178
+ } catch {}
1179
+ const poll = async () => {
1180
+ const r = await fetch('/warmup/status');
1181
+ const d = await r.json();
1182
+ warmupProgress.style.width = d.percent + '%';
1183
+ warmupLogs.innerHTML = d.logs.join('<br>');
1184
+ warmupResume.style.display = d.state === 'error' ? 'inline-block' : 'none';
1185
+ warmupClear.style.display = (d.state === 'done' || d.state === 'error') ? 'inline-block' : 'none';
1186
+ if (d.state === 'error') warmupLogs.classList.add('err'); else warmupLogs.classList.remove('err');
1187
+ if (d.running) setTimeout(poll, 1000);
1188
+ };
1189
+ poll();
1190
+ };
1191
+ warmupResume.onclick = async () => {
1192
+ try {
1193
+ await fetch('/warmup/resume', {method: 'POST'});
1194
+ } catch {}
1195
+ const poll = async () => {
1196
+ const r = await fetch('/warmup/status');
1197
+ const d = await r.json();
1198
+ warmupProgress.style.width = d.percent + '%';
1199
+ warmupLogs.innerHTML = d.logs.join('<br>');
1200
+ warmupResume.style.display = d.state === 'error' ? 'inline-block' : 'none';
1201
+ warmupClear.style.display = (d.state === 'done' || d.state === 'error') ? 'inline-block' : 'none';
1202
+ if (d.state === 'error') warmupLogs.classList.add('err'); else warmupLogs.classList.remove('err');
1203
+ if (d.running) setTimeout(poll, 1000);
1204
+ };
1205
+ poll();
1206
+ };
1207
+ warmupClear.onclick = async () => {
1208
+ await fetch('/warmup/clear_logs', {method: 'POST'});
1209
+ warmupLogs.innerHTML = '';
1210
+ };
1211
+ iaPreviewBtn.onclick = () => { alert('En développement, switch GPU'); };
1212
+ estimateBtn.onclick = async () => {
1213
+ const r = await fetch('/estimate?vid=' + encodeURIComponent(vidName) + '&masks_count=' + masks.length);
1214
+ const d = await r.json();
1215
+ alert(`Temps: ${d.time_min}, VRAM: ${d.vram_gb}`);
1216
+ };
1217
+ setInterval(async () => {
1218
+ if(vidName){
1219
+ const r = await fetch('/progress_ia?vid=' + encodeURIComponent(vidName));
1220
+ const d = await r.json();
1221
+ iaProgress.style.width = d.percent + '%';
1222
+ iaLogs.textContent = d.log;
1223
+ }
1224
+ }, 2000);
1225
+ tutorial.querySelector('button').onclick = () => tutorial.classList.add('hidden');
1226
+ function saveAutoRects(){ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({vid:vidName, idx:currentIdx, rects:rects})); }
1227
+ function loadAutoRects(){
1228
+ try{
1229
+ const d = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) || '{}');
1230
+ if(d.vid === vidName && d.idx === currentIdx){ rects = d.rects || []; draw(); loadMultiMasks(); }
1231
+ }catch{}
1232
+ }
1233
+ // Boot
1234
+ async function boot(){
1235
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1236
+ await loadFiles();
1237
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1238
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1239
+ }
1240
+ boot();
1241
+ // Hide controls in edit-mode
1242
+ const style = document.createElement('style');
1243
+ style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1244
+ document.head.appendChild(style);
1245
+ </script>
1246
+ </html>
1247
  """
 
1248
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1249
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1250
  vid = v or ""
 
1421
  <html lang="fr"><meta charset="utf-8">
1422
  <title>Video Editor</title>
1423
  <style>
1424
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
1425
  *{box-sizing:border-box}
1426
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
1427
  h1{margin:0 0 8px 0}
 
1470
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
1471
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
1472
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
1473
+ #warmupBox{display:flex;align-items:center;gap:8px;margin-bottom:8px}
1474
+ #warmup-progress{background:#f3f4f6;border-radius:4px;height:8px;flex:1}
1475
+ #warmup-progress div{background:#10b981;height:100%;width:0;border-radius:4px}
1476
+ #warmup-resume{margin-left:8px;display:none}
1477
+ #warmup-clear{margin-left:8px;display:none}
1478
+ #warmup-logs{max-height:120px;overflow:auto;font-size:12px;color:#6b7280}
1479
+ .err{color:#ef4444}
1480
  </style>
1481
  <h1>🎬 Video Editor</h1>
1482
  <div class="topbar card">
1483
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
1484
+ <strong>Charger une vidéo :</strong>
1485
+ <input type="file" name="file" accept="video/*" required>
1486
+ <button class="btn" type="submit">Uploader</button>
1487
  </form>
1488
  <span class="muted" id="msg">__MSG__</span>
1489
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
1490
  </div>
1491
+ <div class="card" style="margin-bottom:10px">
1492
+ <div id="warmupBox">
1493
+ <button id="warmupBtn" class="btn">⚡ Warm-up modèles</button>
1494
+ <button id="warmup-resume" class="btn">Reprendre</button>
1495
+ <button id="warmup-clear" class="btn">Effacer logs</button>
1496
+ <div id="warmup-progress"><div></div></div>
1497
+ </div>
1498
+ <div id="warmup-logs" class="muted"></div>
1499
+ </div>
1500
  <div class="layout">
1501
  <div>
1502
  <div class="viewer card" id="viewerCard">
 
1741
  modeLabel.textContent='Édition';
1742
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
1743
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
1744
+ undoBtn.style.display='inline-block'; redoBtn.style.display='inline-block';
1745
  canvas.style.pointerEvents='auto';
1746
+ rects = rectMap.get(currentIdx) || []; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); updateUndoRedo(); loadAutoRects();
1747
  }else{
1748
  player.controls = true;
1749
  playerWrap.classList.remove('edit-mode');
1750
  modeLabel.textContent='Lecture';
1751
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
1752
  btnSave.style.display='none'; btnClear.style.display='none';
1753
+ undoBtn.style.display='none'; redoBtn.style.display='none';
1754
  canvas.style.pointerEvents='none';
1755
+ rects=[]; draw();
1756
  }
1757
  }
1758
  function draw(){
1759
  ctx.clearRect(0,0,canvas.width,canvas.height);
1760
+ rects.forEach(r => {
1761
+ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2);
1762
+ const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1);
1763
+ ctx.strokeStyle=r.color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
1764
+ ctx.fillStyle=r.color+'28'; ctx.fillRect(x,y,w,h);
1765
+ });
1766
  }
1767
  canvas.addEventListener('mousedown',(e)=>{
1768
  if(mode!=='edit' || !vidName) return;
1769
  dragging=true; const r=canvas.getBoundingClientRect();
1770
  sx=e.clientX-r.left; sy=e.clientY-r.top;
1771
+ currentRect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; rects.push(currentRect); draw(); saveAutoRects();
1772
  });
1773
  canvas.addEventListener('mousemove',(e)=>{
1774
  if(!dragging) return;
1775
  const r=canvas.getBoundingClientRect();
1776
+ currentRect.x2=e.clientX-r.left; currentRect.y2=e.clientY-r.top; draw();
1777
  });
1778
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{
1779
+ dragging=false;
1780
+ if(currentRect){ pushHistory(); currentRect = null; }
1781
+ }));
1782
+ function pushHistory(){
1783
+ history = history.slice(0, historyIdx + 1);
1784
+ history.push(JSON.stringify(rects));
1785
+ historyIdx++;
1786
+ updateUndoRedo();
1787
+ saveAutoRects();
1788
+ }
1789
+ function updateUndoRedo(){
1790
+ undoBtn.disabled = historyIdx <= 0;
1791
+ redoBtn.disabled = historyIdx >= history.length - 1;
1792
+ }
1793
+ undoBtn.onclick = () => {
1794
+ if(historyIdx > 0){ historyIdx--; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); loadMultiMasks(); }
1795
+ };
1796
+ redoBtn.onclick = () => {
1797
+ if(historyIdx < history.length - 1){ historyIdx++; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); loadMultiMasks(); }
1798
+ };
1799
+ btnClear.onclick=()=>{ if(rects.length) rects.pop(); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
1800
  btnEdit.onclick =()=> setMode('edit');
1801
  btnBack.onclick =()=> setMode('view');
1802
  // Palette
 
1805
  el.onclick=()=>{
1806
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
1807
  el.classList.add('sel'); color=el.dataset.c;
1808
+ if(currentRect){ currentRect.color=color; draw(); }
1809
  };
1810
  });
1811
  // === Timeline ===
 
1830
  }
1831
  if(portionStart!=null && portionEnd!=null){
1832
  const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
1833
+ const batchSize = 200; // load large portions in batches
1834
+ for(let bstart=s; bstart<e; bstart+=batchSize){
1835
+ const bend = Math.min(bstart + batchSize, e);
1836
+ for(let i=bstart;i<bend;i++){ addThumb(i,'append'); }
1837
+ await new Promise(r=>setTimeout(r, 50)); // UI breath
1838
+ }
1839
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
1840
  return;
1841
  }
 
1846
  tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
1847
  const rngStart = (viewRangeStart ?? 0);
1848
  const rngEnd = (viewRangeEnd ?? frames);
1849
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
1850
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
1851
  const end = Math.min(rngEnd, start + chunkSize);
1852
  for(let i=start;i<end;i++){ addThumb(i,'append'); }
1853
  timelineStart = start; timelineEnd = end;
 
1878
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
1879
  img.onclick=async ()=>{
1880
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
1881
+ if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1882
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
1883
  };
1884
  wrap.appendChild(img);
1885
  const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
1886
  wrap.appendChild(label);
1887
  if(place==='append'){ tlBox.appendChild(wrap); }
1888
+ else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
1889
  else{ tlBox.appendChild(wrap); }
1890
  thumbEls.set(idx, wrap);
1891
  }