FABLESLIP commited on
Commit
d9a75ff
·
verified ·
1 Parent(s): 949afea

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +631 -696
app.py CHANGED
@@ -1,109 +1,433 @@
1
- # app.py — Video Editor API (v0.8.0)
2
- # v0.8.0: Chargement modèles Hub, stubs IA, + Améliorations : multi-masques, estimation /estimate, progression /progress_ia
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
- # ... (code pointeur)
18
- print("[BOOT] Starting...")
19
- app = FastAPI(title="Video Editor API", version="0.8.0")
20
- # ... (DATA_DIR, mounts inchangés)
21
- # --- Chargement Modèles au Boot ---
22
- def load_model(repo_id):
23
- path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
24
- if not path.exists() or not any(path.iterdir()):
25
- print(f"[BOOT] Downloading {repo_id}...")
26
- hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
27
- (path / "loaded.ok").touch()
28
- # Symlink exemples
29
- if "sam2" in repo_id: shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
30
- # Ajoute pour autres
31
- models = [
32
- "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
33
- "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
34
- ]
35
- Parallel(n_jobs=4)(delayed(load_model)(m) for m in models)
36
- # ProPainter wget
37
- PROP = Path("/app/propainter")
38
- PROP.mkdir(exist_ok=True)
39
- def wget(url, dest):
40
- if not (dest / url.split("/")[-1]).exists():
41
- subprocess.run(["wget", "-q", url, "-P", str(dest)])
42
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth", PROP)
43
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth", PROP)
44
- wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth", PROP)
45
- print("[BOOT] Models ready.")
46
- # --- PROXY (inchangé) ---
47
- # ... (code proxy)
48
- # Helpers + is_gpu() (inchangé)
49
- # API (Ajouts IA stubs + Nouveaux pour Améliorations)
50
- @app.post("/mask/ai")
51
- async def mask_ai(payload: Dict[str, Any] = Body(...)):
52
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
53
- # TODO: Impl SAM2
54
- return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
55
- @app.post("/inpaint")
56
- async def inpaint(payload: Dict[str, Any] = Body(...)):
57
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
58
- # TODO: Impl DiffuEraser, update progress_ia
59
- return {"ok": True, "preview": "/data/preview.mp4"}
60
- @app.get("/estimate")
61
- def estimate(vid: str, masks_count: int):
62
- # TODO: Calcul simple (frames * masks * facteur GPU)
63
- return {"time_min": 5, "vram_gb": 4}
64
- @app.get("/progress_ia")
65
- def progress_ia(vid: str):
66
- # TODO: Retourne % et logs (e.g., {"percent": 50, "log": "Frame 25/50"})
67
- return {"percent": 0, "log": "En cours..."}
68
- # ... (autres routes inchangées, étend /mask pour multi-masques array)
69
- # UI (Ajoute undo/redo boutons, preview popup, tutoriel , auto-save JS, feedback barre)
70
- HTML_TEMPLATE = r"""
71
- Video Editor
72
- # ... (topbar, layout inchangés)
73
- # ... (modes, boutons inchangés)
74
- ↩️ Undo
75
- ↪️ Redo
76
- # ... (palette, masques liste pour multi)
77
- Progression IA:
78
- Tutoriel (cliquer pour masquer)
79
- 1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.
80
- """
81
- # ... (ui func inchangée)
82
- @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
83
- def ui(v: Optional[str] = "", msg: Optional[str] = ""):
84
- vid = v or ""
85
- try:
86
- msg = urllib.parse.unquote(msg or "")
87
- except Exception:
88
- pass
89
- html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
90
- return HTMLResponse(content=html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  @app.get("/", tags=["meta"])
92
  def root():
93
  return {
94
  "ok": True,
95
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
96
  }
 
97
  @app.get("/health", tags=["meta"])
98
  def health():
99
  return {"status": "ok"}
 
100
  @app.get("/_env", tags=["meta"])
101
  def env_info():
102
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
 
103
  @app.get("/files", tags=["io"])
104
  def files():
105
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
106
  return {"count": len(items), "items": items}
 
107
  @app.get("/meta/{vid}", tags=["io"])
108
  def video_meta(vid: str):
109
  v = DATA_DIR / vid
@@ -113,6 +437,7 @@ def video_meta(vid: str):
113
  if not m:
114
  raise HTTPException(500, "Métadonnées indisponibles")
115
  return m
 
116
  @app.post("/upload", tags=["io"])
117
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
118
  ext = (Path(file.filename).suffix or ".mp4").lower()
@@ -133,9 +458,11 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
133
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
134
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
135
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
 
136
  @app.get("/progress/{vid_stem}", tags=["io"])
137
  def progress(vid_stem: str):
138
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
 
139
  @app.delete("/delete/{vid}", tags=["io"])
140
  def delete_video(vid: str):
141
  v = DATA_DIR / vid
@@ -148,6 +475,7 @@ def delete_video(vid: str):
148
  v.unlink(missing_ok=True)
149
  print(f"[DELETE] {vid}", file=sys.stdout)
150
  return {"deleted": vid}
 
151
  @app.get("/frame_idx", tags=["io"])
152
  def frame_idx(vid: str, idx: int):
153
  v = DATA_DIR / vid
@@ -163,6 +491,7 @@ def frame_idx(vid: str, idx: int):
163
  except Exception as e:
164
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
165
  raise HTTPException(500, "Frame error")
 
166
  @app.get("/poster/{vid}", tags=["io"])
167
  def poster(vid: str):
168
  v = DATA_DIR / vid
@@ -172,8 +501,13 @@ def poster(vid: str):
172
  if p.exists():
173
  return FileResponse(str(p), media_type="image/jpeg")
174
  raise HTTPException(404, "Poster introuvable")
 
175
  @app.get("/window/{vid}", tags=["io"])
176
- def window(vid: str, center: int = 0, count: int = 21):
 
 
 
 
177
  v = DATA_DIR / vid
178
  if not v.exists():
179
  raise HTTPException(404, "Vidéo introuvable")
@@ -182,9 +516,24 @@ def window(vid: str, center: int = 0, count: int = 21):
182
  raise HTTPException(500, "Métadonnées indisponibles")
183
  frames = m["frames"]
184
  count = max(3, int(count))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  center = max(0, min(int(center), max(0, frames-1)))
186
  if frames <= 0:
187
- print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
188
  return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
189
  if frames <= count:
190
  start = 0; sel = center; n = frames
@@ -197,65 +546,79 @@ def window(vid: str, center: int = 0, count: int = 21):
197
  idx = start + i
198
  url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
199
  items.append({"i": i, "idx": idx, "url": url})
200
- print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
201
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
202
- # ----- Masques -----
 
203
  @app.post("/mask", tags=["mask"])
204
  async def save_mask(payload: Dict[str, Any] = Body(...)):
205
- vid = payload.get("vid")
206
- if not vid:
207
- raise HTTPException(400, "vid manquant")
208
- pts = payload.get("points") or []
209
- if len(pts) != 4:
210
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
211
- data = _load_masks(vid)
212
- m = {
213
- "id": uuid.uuid4().hex[:10],
214
- "time_s": float(payload.get("time_s") or 0.0),
215
- "frame_idx": int(payload.get("frame_idx") or 0),
216
- "shape": "rect",
217
- "points": [float(x) for x in pts],
218
- "color": payload.get("color") or "#10b981",
219
- "note": payload.get("note") or ""
220
- }
221
- data.setdefault("masks", []).append(m)
222
- _save_masks(vid, data)
223
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
224
- return {"saved": True, "mask": m}
 
 
 
 
 
 
 
 
 
 
 
 
225
  @app.get("/mask/{vid}", tags=["mask"])
226
  def list_masks(vid: str):
227
  return _load_masks(vid)
 
228
  @app.post("/mask/rename", tags=["mask"])
229
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
230
- vid = payload.get("vid")
231
- mid = payload.get("id")
232
- new_note = (payload.get("note") or "").strip()
233
  if not vid or not mid:
234
  raise HTTPException(400, "vid et id requis")
235
  data = _load_masks(vid)
236
  for m in data.get("masks", []):
237
  if m.get("id") == mid:
238
  m["note"] = new_note
239
- _save_masks(vid, data)
240
  return {"ok": True}
241
  raise HTTPException(404, "Masque introuvable")
 
242
  @app.post("/mask/delete", tags=["mask"])
243
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
244
- vid = payload.get("vid")
245
- mid = payload.get("id")
246
  if not vid or not mid:
247
  raise HTTPException(400, "vid et id requis")
248
  data = _load_masks(vid)
 
249
  data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
250
- _save_masks(vid, data)
251
- return {"ok": True}
252
- # ---------- UI ----------
 
 
253
  HTML_TEMPLATE = r"""
254
  <!doctype html>
255
  <html lang="fr"><meta charset="utf-8">
256
- <title>Video Editor</title>
257
  <style>
258
- :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
259
  *{box-sizing:border-box}
260
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
261
  h1{margin:0 0 8px 0}
@@ -264,19 +627,18 @@ HTML_TEMPLATE = r"""
264
  .muted{color:var(--muted);font-size:13px}
265
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
266
  .viewer{max-width:1024px;margin:0 auto; position:relative}
267
- .player-wrap{position:relative; padding-bottom: var(--controlsH);}
268
  video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
269
  #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
270
  .timeline-container{margin-top:10px}
271
  .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%}
272
  .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
273
  .thumb:hover{transform:scale(1.05)}
274
- .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
275
  .thumb img.sel{border-color:var(--active-border)}
276
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
277
  .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}
278
  .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
279
- .timeline.filter-masked .thumb:not(.hasmask){display:none}
280
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
281
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
282
  .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
@@ -290,20 +652,17 @@ HTML_TEMPLATE = r"""
290
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
291
  .delete-btn:hover{color:#b91c1c}
292
  #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
293
- .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
294
- .portion-input{width:70px}
295
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
296
  #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
297
  #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}
298
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
299
  #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
300
  #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
301
- .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
302
- #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
303
- #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}
304
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
305
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
306
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
 
 
307
  </style>
308
  <h1>🎬 Video Editor</h1>
309
  <div class="topbar card">
@@ -314,6 +673,9 @@ HTML_TEMPLATE = r"""
314
  </form>
315
  <span class="muted" id="msg">__MSG__</span>
316
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
 
 
 
317
  </div>
318
  <div class="layout">
319
  <div>
@@ -330,13 +692,11 @@ HTML_TEMPLATE = r"""
330
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
331
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
332
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
333
- <span id="posInfo" style="margin-left:10px"></span>
334
- <span id="status" style="margin-left:10px;color:#2563eb"></span>
335
  </div>
336
  </div>
337
  <div class="card timeline-container">
338
- <h4 style="margin:2px 0 8px 0">Timeline</h4>
339
- <div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center">
340
  <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
341
  <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
342
  <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
@@ -346,21 +706,20 @@ HTML_TEMPLATE = r"""
346
  </div>
347
  <div id="timeline" class="timeline"></div>
348
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
349
- <div id="loading-indicator">Chargement des frames...</div>
350
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
351
  </div>
352
  </div>
353
  <div class="card tools">
354
- <div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
355
- <div class="row" style="margin-top:6px">
356
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
357
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
358
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
359
- <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
360
  </div>
361
  <div style="margin-top:10px">
362
  <div class="muted">Couleur</div>
363
- <div class="row" id="palette" style="margin-top:6px">
364
  <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
365
  <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
366
  <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
@@ -378,587 +737,163 @@ HTML_TEMPLATE = r"""
378
  <strong>Vidéos disponibles</strong>
379
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
380
  </div>
381
- </div>
382
  </div>
383
  </div>
384
- <div id="popup">
385
- <h3>Génération thumbs en cours</h3>
386
- <div id="popup-progress-bar"><div id="popup-progress-fill"></div></div>
387
- <div id="popup-logs"></div>
388
- </div>
389
  <div id="toast"></div>
390
  <script>
391
- const serverVid = "__VID__";
392
- const serverMsg = "__MSG__";
393
- document.getElementById('msg').textContent = serverMsg;
394
- // Elements
395
- const statusEl = document.getElementById('status');
396
- const player = document.getElementById('player');
397
- const srcEl = document.getElementById('vidsrc');
398
- const canvas = document.getElementById('editCanvas');
399
- const ctx = canvas.getContext('2d');
400
- const modeLabel = document.getElementById('modeLabel');
401
- const btnEdit = document.getElementById('btnEdit');
402
- const btnBack = document.getElementById('btnBack');
403
- const btnSave = document.getElementById('btnSave');
404
- const btnClear= document.getElementById('btnClear');
405
- const posInfo = document.getElementById('posInfo');
406
- const goFrame = document.getElementById('goFrame');
407
- const palette = document.getElementById('palette');
408
- const fileList= document.getElementById('fileList');
409
- const tlBox = document.getElementById('timeline');
410
- const tlNote = document.getElementById('tlNote');
411
- const playerWrap = document.getElementById('playerWrap');
412
- const loadingInd = document.getElementById('loading-indicator');
413
- const isolerBoucle = document.getElementById('isolerBoucle');
414
- const resetFull = document.getElementById('resetFull');
415
- const endPortion = document.getElementById('endPortion');
416
- const popup = document.getElementById('popup');
417
- const popupLogs = document.getElementById('popup-logs');
418
- const tlProgressFill = document.getElementById('tl-progress-fill');
419
- const popupProgressFill = document.getElementById('popup-progress-fill');
420
- const btnFollow = document.getElementById('btnFollow');
421
- const btnFilterMasked = document.getElementById('btnFilterMasked');
422
- const zoomSlider = document.getElementById('zoomSlider');
423
- const maskedCount = document.getElementById('maskedCount');
424
- const hud = document.getElementById('hud');
425
  const toastWrap = document.getElementById('toast');
426
- const gotoInput = document.getElementById('gotoInput');
427
- const gotoBtn = document.getElementById('gotoBtn');
428
- // State
429
- let vidName = serverVid || '';
430
- function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
431
- let vidStem = '';
432
- let bustToken = Date.now();
433
- let fps = 30, frames = 0;
434
- let currentIdx = 0;
435
  let mode = 'view';
436
- let rect = null, dragging=false, sx=0, sy=0;
437
  let color = '#10b981';
438
- let rectMap = new Map();
439
- let masks = [];
440
- let maskedSet = new Set();
441
- let timelineUrls = [];
442
- let portionStart = null;
443
- let portionEnd = null;
444
- let loopInterval = null;
445
- let chunkSize = 50;
446
- let timelineStart = 0, timelineEnd = 0;
447
- let viewRangeStart = 0, viewRangeEnd = 0; // portée visible (portion ou full)
448
- const scrollThreshold = 100;
449
- let followMode = false;
450
- let isPaused = true;
451
- let thumbEls = new Map();
452
- let lastCenterMs = 0;
453
- const CENTER_THROTTLE_MS = 150;
454
- const PENDING_KEY = 've_pending_masks_v1';
455
- let maskedOnlyMode = false;
456
- // Utils
457
- function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
458
- function ensureOverlays(){
459
- if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
460
- if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
461
- if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
462
- if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
463
- }
464
- function playheadEl(){ return document.getElementById('playhead'); }
465
- function portionBand(){ return document.getElementById('portionBand'); }
466
- function inHandle(){ return document.getElementById('inHandle'); }
467
- function outHandle(){ return document.getElementById('outHandle'); }
468
- function findThumbEl(idx){ return thumbEls.get(idx) || null; }
469
- function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
470
- function updateSelectedThumb(){
471
- tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
472
- const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
473
- img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
474
- }
475
- function rawCenterThumb(el){
476
- tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
477
- }
478
- async function ensureThumbVisibleCentered(idx){
479
- // Attendre que l’élément + image soient prêts avant de centrer
480
- for(let k=0; k<40; k++){
481
- const el = findThumbEl(idx);
482
- if(el){
483
- const img = el.querySelector('img');
484
- if(!img.complete || img.naturalWidth === 0){
485
- await new Promise(r=>setTimeout(r, 25));
486
- }else{
487
- rawCenterThumb(el);
488
- updatePlayhead();
489
- return true;
490
- }
491
- }else{
492
- await new Promise(r=>setTimeout(r, 25));
493
- }
494
- }
495
- return false;
496
- }
497
- function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
498
- function updatePlayhead(){
499
- ensureOverlays();
500
- const el = findThumbEl(currentIdx);
501
- const ph = playheadEl();
502
- if(!el){ ph.style.display='none'; return; }
503
- ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
504
- }
505
- function updatePortionOverlays(){
506
- ensureOverlays();
507
- const pb = portionBand(), ih = inHandle(), oh = outHandle();
508
- if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
509
- const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
510
- if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
511
- const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
512
- pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
513
- ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
514
- oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
515
- }
516
- function nearestFrameIdxFromClientX(clientX){
517
- const rect = tlBox.getBoundingClientRect();
518
- const xIn = clientX - rect.left + tlBox.scrollLeft;
519
- let bestIdx = currentIdx, bestDist = Infinity;
520
- for(const [idx, el] of thumbEls.entries()){
521
- const mid = el.offsetLeft + el.clientWidth/2;
522
- const d = Math.abs(mid - xIn);
523
- if(d < bestDist){ bestDist = d; bestIdx = idx; }
524
- }
525
- return bestIdx;
526
- }
527
- function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
528
- function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
529
- function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
530
- async function flushPending(){
531
- const lst = loadPending(); if(!lst.length) return;
532
- const kept = [];
533
- for(const p of lst){
534
- try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
535
- catch{ kept.push(p); }
536
- }
537
- savePendingList(kept);
538
- if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
539
- }
540
- // Layout
541
- function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
542
- function fitCanvas(){
543
- const r=player.getBoundingClientRect();
544
- const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
545
- canvas.width=Math.round(r.width);
546
- canvas.height=Math.round(r.height - ctrlH);
547
- canvas.style.width=r.width+'px';
548
- canvas.style.height=(r.height - ctrlH)+'px';
549
- syncTimelineWidth();
550
- }
551
- function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
552
- function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
553
- function setMode(m){
554
- mode=m;
555
- if(m==='edit'){
556
- player.pause();
557
- player.controls = false;
558
- playerWrap.classList.add('edit-mode');
559
- modeLabel.textContent='Édition';
560
- btnEdit.style.display='none'; btnBack.style.display='inline-block';
561
- btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
562
- canvas.style.pointerEvents='auto';
563
- rect = rectMap.get(currentIdx) || null; draw();
564
- }else{
565
- player.controls = true;
566
- playerWrap.classList.remove('edit-mode');
567
- modeLabel.textContent='Lecture';
568
- btnEdit.style.display='inline-block'; btnBack.style.display='none';
569
- btnSave.style.display='none'; btnClear.style.display='none';
570
- canvas.style.pointerEvents='none';
571
- rect=null; draw();
572
- }
573
- }
574
- function draw(){
575
- ctx.clearRect(0,0,canvas.width,canvas.height);
576
- if(rect){
577
- const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
578
- const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
579
- ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
580
- ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
581
- }
582
  }
583
- canvas.addEventListener('mousedown',(e)=>{
584
- if(mode!=='edit' || !vidName) return;
585
- dragging=true; const r=canvas.getBoundingClientRect();
586
- sx=e.clientX-r.left; sy=e.clientY-r.top;
587
- rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
588
- });
589
- canvas.addEventListener('mousemove',(e)=>{
590
- if(!dragging) return;
591
- const r=canvas.getBoundingClientRect();
592
- rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
593
- });
594
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
595
- btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
 
596
  btnEdit.onclick =()=> setMode('edit');
597
  btnBack.onclick =()=> setMode('view');
 
 
 
598
  // Palette
599
- palette.querySelectorAll('.swatch').forEach(el=>{
600
- if(el.dataset.c===color) el.classList.add('sel');
601
- el.onclick=()=>{
602
- palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
603
- el.classList.add('sel'); color=el.dataset.c;
604
- if(rect){ rect.color=color; draw(); }
605
- };
606
- });
607
- // === Timeline ===
608
- async function loadTimelineUrls(){
609
- timelineUrls = [];
610
- const stem = vidStem, b = bustToken;
611
- for(let idx=0; idx<frames; idx++){
612
- timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`;
613
- }
614
- tlProgressFill.style.width='0%';
615
- }
616
- async function renderTimeline(centerIdx){
617
- if(!vidName) return;
618
- loadingInd.style.display='block';
619
- if(timelineUrls.length===0) await loadTimelineUrls();
620
- tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
621
- if(maskedOnlyMode){
622
- const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
623
- for(const i of idxs){ addThumb(i,'append'); }
624
- setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
625
- return;
626
- }
627
- if(portionStart!=null && portionEnd!=null){
628
- const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
629
- for(let i=s;i<e;i++){ addThumb(i,'append'); }
630
- setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
631
- return;
632
- }
633
- await loadWindow(centerIdx ?? currentIdx);
634
- loadingInd.style.display='none';
635
- }
636
- async function loadWindow(centerIdx){
637
- tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
638
- const rngStart = (viewRangeStart ?? 0);
639
- const rngEnd = (viewRangeEnd ?? frames);
640
- const mid = Math.max(rngStart, Math.min(centerIdx, max(rngStart, rngEnd-1)));
641
- const start = Math.max(rngStart, min(mid - Math.floor(chunkSize/2), max(rngStart, rngEnd - chunkSize)));
642
- const end = Math.min(rngEnd, start + chunkSize);
643
- for(let i=start;i<end;i++){ addThumb(i,'append'); }
644
- timelineStart = start; timelineEnd = end;
645
- setTimeout(async ()=>{
646
- syncTimelineWidth();
647
- updateSelectedThumb();
648
- await ensureThumbVisibleCentered(currentIdx);
649
- updatePortionOverlays();
650
- },0);
651
- }
652
- function addThumb(idx, place='append'){
653
- if(thumbEls.has(idx)) return;
654
- const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
655
- if(maskedSet.has(idx)) wrap.classList.add('hasmask');
656
- const img=new Image(); img.title='frame '+(idx+1);
657
- img.src=timelineUrls[idx];
658
- img.onerror = () => {
659
- const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
660
- img.onerror = null;
661
- img.src = fallback;
662
- img.onload = () => {
663
- const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
664
- timelineUrls[idx] = nu;
665
- img.src = nu;
666
- img.onload = null;
667
- };
668
- };
669
- if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
670
- img.onclick=async ()=>{
671
- currentIdx=idx; player.currentTime=idxToSec(currentIdx);
672
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
673
- updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
674
- };
675
- wrap.appendChild(img);
676
- const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
677
- wrap.appendChild(label);
678
- if(place==='append'){ tlBox.appendChild(wrap); }
679
- else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
680
- else{ tlBox.appendChild(wrap); }
681
- thumbEls.set(idx, wrap);
682
- }
683
- // Scroll chunk (mode normal uniquement)
684
- tlBox.addEventListener('scroll', ()=>{
685
- if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
686
- updatePlayhead(); updatePortionOverlays();
687
- return;
688
- }
689
- const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
690
- if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
691
- const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
692
- for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
693
- timelineEnd = newEnd;
694
- }
695
- if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
696
- const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
697
- for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
698
- tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
699
- timelineStart = newStart;
700
- }
701
- updatePlayhead(); updatePortionOverlays();
702
- });
703
- // Isoler & Boucle
704
- isolerBoucle.onclick = async ()=>{
705
- const start = parseInt(goFrame.value || '1',10) - 1;
706
- const end = parseInt(endPortion.value || '',10);
707
- if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
708
- if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
709
- portionStart = start; portionEnd = end;
710
- viewRangeStart = start; viewRangeEnd = end;
711
- player.pause(); isPaused = true;
712
- currentIdx = start; player.currentTime = idxToSec(start);
713
- await renderTimeline(currentIdx);
714
- resetFull.style.display = 'inline-block';
715
- startLoop(); updatePortionOverlays();
716
- };
717
- function startLoop(){
718
- if(loopInterval) clearInterval(loopInterval);
719
- if(portionEnd != null){
720
- loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
721
- }
722
- }
723
- resetFull.onclick = async ()=>{
724
- portionStart = null; portionEnd = null;
725
- viewRangeStart = 0; viewRangeEnd = frames;
726
- goFrame.value = 1; endPortion.value = '';
727
- player.pause(); isPaused = true;
728
- await renderTimeline(currentIdx);
729
- resetFull.style.display='none';
730
- clearInterval(loopInterval); updatePortionOverlays();
731
- };
732
- // Drag IN/OUT
733
- function attachHandleDrag(handle, which){
734
- let draggingH=false;
735
- function onMove(e){
736
- if(!draggingH) return;
737
- const idx = nearestFrameIdxFromClientX(e.clientX);
738
- if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
739
- else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
740
- viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
741
- updatePortionOverlays();
742
- }
743
- handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
744
- window.addEventListener('mousemove', onMove);
745
- window.addEventListener('mouseup', ()=>{ draggingH=false; });
746
- }
747
- ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
748
- // Progress popup
749
- async function showProgress(vidStem){
750
- popup.style.display = 'block';
751
- const interval = setInterval(async () => {
752
- const r = await fetch('/progress/' + vidStem);
753
- const d = await r.json();
754
- tlProgressFill.style.width = d.percent + '%';
755
- popupProgressFill.style.width = d.percent + '%';
756
- popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
757
- if(d.done){
758
- clearInterval(interval);
759
- popup.style.display = 'none';
760
- await renderTimeline(currentIdx);
761
- }
762
- }, 800);
763
- }
764
- // Meta & boot
765
- async function loadVideoAndMeta() {
766
- if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
767
- vidStem = fileStem(vidName); bustToken = Date.now();
768
- const bust = Date.now();
769
- srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
770
- player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
771
- player.load();
772
- fitCanvas();
773
- statusEl.textContent = 'Chargement vidéo…';
774
- try{
775
- const r=await fetch('/meta/'+encodeURIComponent(vidName));
776
- if(r.ok){
777
- const m=await r.json();
778
- fps=m.fps||30; frames=m.frames||0;
779
- statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
780
- viewRangeStart = 0; viewRangeEnd = frames;
781
- await loadTimelineUrls();
782
- await loadMasks();
783
- currentIdx = 0; player.currentTime = 0;
784
- await renderTimeline(0);
785
- showProgress(vidStem);
786
- }else{
787
- statusEl.textContent = 'Erreur meta';
788
- }
789
- }catch(err){
790
- statusEl.textContent = 'Erreur réseau meta';
791
- }
792
- }
793
- player.addEventListener('loadedmetadata', async ()=>{
794
- fitCanvas();
795
- if(!frames || frames<=0){
796
- 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{}
797
- }
798
- currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
799
- });
800
- window.addEventListener('resize', ()=>{ fitCanvas(); });
801
- player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
802
- player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
803
- player.addEventListener('timeupdate', ()=>{
804
- posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
805
- currentIdx=timeToIdx(player.currentTime);
806
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
807
- updateHUD(); updateSelectedThumb(); updatePlayhead();
808
- if(followMode && !isPaused){
809
- const now = Date.now();
810
- if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
811
- }
812
- });
813
- goFrame.addEventListener('change', async ()=>{
814
- if(!vidName) return;
815
- const val=Math.max(1, parseInt(goFrame.value||'1',10));
816
- player.pause(); isPaused = true;
817
- currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
818
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
819
- await renderTimeline(currentIdx);
820
- await ensureThumbVisibleCentered(currentIdx);
821
- });
822
- // Follow / Filter / Zoom / Goto
823
- btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
824
- btnFilterMasked.onclick = async ()=>{
825
- maskedOnlyMode = !maskedOnlyMode;
826
- btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
827
- tlBox.classList.toggle('filter-masked', maskedOnlyMode);
828
- await renderTimeline(currentIdx);
829
- await ensureThumbVisibleCentered(currentIdx);
830
- };
831
- zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
832
- async function gotoFrameNum(){
833
- const v = parseInt(gotoInput.value||'',10);
834
- if(!Number.isFinite(v) || v<1 || v>frames) return;
835
- player.pause(); isPaused = true;
836
- currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
837
- goFrame.value = v;
838
- await renderTimeline(currentIdx);
839
- await ensureThumbVisibleCentered(currentIdx);
840
  }
841
- gotoBtn.onclick = ()=>{ gotoFrameNum(); };
842
- gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
843
- // Drag & drop upload
844
- const uploadZone = document.getElementById('uploadForm');
845
- uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
846
- uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
847
- uploadZone.addEventListener('drop', (e) => {
848
- e.preventDefault(); uploadZone.style.borderColor = 'transparent';
849
- const file = e.dataTransfer.files[0];
850
- if(file && file.type.startsWith('video/')){
851
- const fd = new FormData(); fd.append('file', file);
852
- fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
853
  }
854
- });
855
- // Export placeholder
856
- document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
857
- // Fichiers & masques
858
- async function loadFiles(){
859
- const r=await fetch('/files'); const d=await r.json();
860
- if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
861
- fileList.innerHTML='';
862
- d.items.forEach(name=>{
863
- const li=document.createElement('li');
864
- const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
865
- delBtn.onclick=async()=>{
866
- if(!confirm(`Supprimer "${name}" ?`)) return;
867
- await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
868
- loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
869
- };
870
- const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
871
- li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
872
- });
873
  }
874
- async function loadMasks(){
875
- loadingInd.style.display='block';
876
- const box=document.getElementById('maskList');
877
- const r=await fetch('/mask/'+encodeURIComponent(vidName));
878
- const d=await r.json();
879
- masks=d.masks||[];
880
- maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
881
- rectMap.clear();
882
- masks.forEach(m=>{
883
- if(m.shape==='rect'){
884
- const [x1,y1,x2,y2] = m.points;
885
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
886
- rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
887
- }
888
- });
889
- maskedCount.textContent = `(${maskedSet.size} ⭐)`;
890
- if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
891
- box.innerHTML='';
892
- const ul=document.createElement('ul'); ul.className='clean';
893
- masks.forEach(m=>{
894
- const li=document.createElement('li');
895
- const fr=(parseInt(m.frame_idx||0,10)+1);
896
- const t=(m.time_s||0).toFixed(2);
897
- const col=m.color||'#10b981';
898
- const label=m.note||(`frame ${fr}`);
899
- 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`;
900
- const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
901
- renameBtn.onclick=async()=>{
902
- const nv=prompt('Nouveau nom du masque :', label);
903
- if(nv===null) return;
904
- const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
905
- if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
906
- const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
907
- }
908
- };
909
- const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
910
- delMaskBtn.onclick=async()=>{
911
- if(!confirm(`Supprimer masque "${label}" ?`)) return;
912
- const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
913
- if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
914
- const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
915
- }
916
- };
917
- li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
918
- });
919
- box.appendChild(ul);
920
- loadingInd.style.display='none';
921
- }
922
- // Save mask (+ cache)
923
  btnSave.onclick = async ()=>{
924
- if(!rect || !vidName){ alert('Aucune sélection.'); return; }
925
- const defaultName = `frame ${currentIdx+1}`;
926
- const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
927
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
928
- const x=Math.min(rect.x1,rect.x2)/normW;
929
- const y=Math.min(rect.y1,rect.y2)/normH;
930
- const w=Math.abs(rect.x2-rect.x1)/normW;
931
- const h=Math.abs(rect.y2-rect.y1)/normH;
932
- 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};
933
- addPending(payload);
934
- try{
935
- const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
936
- if(r.ok){
937
- const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
938
- savePendingList(lst);
939
- rectMap.set(currentIdx,{...rect});
940
- await loadMasks(); await renderTimeline(currentIdx);
941
- showToast('Masque enregistré ✅');
942
- } else {
943
- const txt = await r.text();
944
- alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
945
- }
946
- }catch(e){
947
- alert('Erreur réseau lors de l’enregistrement du masque.');
948
- }
949
  };
950
- // Boot
951
- async function boot(){
952
- const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
953
- await loadFiles();
954
- if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
955
- else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  }
 
 
957
  boot();
958
- // Hide controls in edit-mode
959
- const style = document.createElement('style');
960
- style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
961
- document.head.appendChild(style);
962
  </script>
963
  </html>
964
- """
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — Video Editor API (v0.8.3)
2
+ #
3
+ # Changements clés v0.8.3 par rapport à v0.8.2/v0.5.9 :
4
+ # - **Multi-rectangles par frame** côté UI: les rectangles enregistrés ne disparaissent plus.
5
+ # • rectMap: Map[int, List[RectNorm]] (coordonnées normalisées 0..1).
6
+ # • Le dessin superpose tous les masques enregistrés sur la frame courante + le brouillon en cours.
7
+ # • Après un save, le brouillon est vidé mais les masques existants restent affichés.
8
+ # - **Sauvegarde de masques robuste**: verrou global + écriture atomique (tmp + os.replace) pour éviter JSON corrompu
9
+ # en cas de double-clic/requests concurrentes. Retour d'erreur JSON propre au lieu d'une 500 HTML générique.
10
+ # - **Warm-up modèle**: déjà OK chez toi; ici on garde la version robuste (heartbeat, logs, poursuite en cas d'échec).
11
+ # • Pas d'échec si HfHubHTTPError indisponible: fallback sur Exception.
12
+ # • /warmup/clear pour purger les logs depuis l'UI.
13
+ # - **Aller à #** centré pixel-perfect (conservé) + portion 100% fiable (début inclus, fin exclusive).
14
+ #
15
+ # NB: Ce fichier est autonome (FastAPI + UI HTML/JS).
16
+
17
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
18
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
19
  from fastapi.staticfiles import StaticFiles
20
  from pathlib import Path
21
+ from typing import Optional, Dict, Any, List
22
  import uuid, shutil, cv2, json, time, urllib.parse, sys
23
  import threading
24
  import subprocess
25
  import shutil as _shutil
26
  import os
27
  import httpx
28
+
29
+ print("[BOOT] Video Editor API starting…")
30
+
31
+ # ----------------------- POINTEUR BACKEND (inchangé) ------------------------
32
+ POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
33
+ FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
34
+ _backend_url_cache = {"url": None, "ts": 0.0}
35
+
36
+ def get_backend_base() -> str:
37
+ try:
38
+ if POINTER_URL:
39
+ now = time.time()
40
+ need_refresh = (not _backend_url_cache["url"]) or (now - _backend_url_cache["ts"] > 30)
41
+ if need_refresh:
42
+ r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
43
+ url = (r.text or "").strip()
44
+ if url.startswith("http"):
45
+ _backend_url_cache["url"] = url
46
+ _backend_url_cache["ts"] = now
47
+ else:
48
+ return FALLBACK_BASE
49
+ return _backend_url_cache["url"] or FALLBACK_BASE
50
+ return FALLBACK_BASE
51
+ except Exception:
52
+ return FALLBACK_BASE
53
+
54
+ print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
55
+ print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
56
+
57
+ app = FastAPI(title="Video Editor API", version="0.8.3")
58
+
59
+ DATA_DIR = Path("/app/data")
60
+ THUMB_DIR = DATA_DIR / "_thumbs"
61
+ MASK_DIR = DATA_DIR / "_masks"
62
+ for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
63
+ p.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Verrou pour écriture JSON des masques (anti course)
66
+ _mask_io_lock = threading.Lock()
67
+
68
+ app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
69
+ app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
70
+
71
+ # ----------------------------- WARM-UP STATE --------------------------------
72
+ _warmup_state = {
73
+ "status": "idle", # idle|running|done|error
74
+ "current": None, # repo_id en cours
75
+ "done": [], # repos OK
76
+ "errors": [], # tuples (repo, str(err))
77
+ "logs": [], # dernières lignes
78
+ "started_at": None,
79
+ "ended_at": None,
80
+ }
81
+
82
+ _warmup_lock = threading.Lock()
83
+
84
+
85
+ def _warm_log(msg: str):
86
+ with _warmup_lock:
87
+ ts = time.strftime("%H:%M:%S")
88
+ _warmup_state["logs"].append(f"[{ts}] {msg}")
89
+ # Conserver les 500 dernières lignes max
90
+ if len(_warmup_state["logs"]) > 500:
91
+ _warmup_state["logs"] = _warmup_state["logs"][-500:]
92
+
93
+
94
+ def _do_warmup():
95
+ """Télécharge les modèles depuis le Hub de façon séquentielle.
96
+ S'il y a erreur sur un repo, on log et on continue les suivants.
97
+ """
98
+ with _warmup_lock:
99
+ _warmup_state.update({
100
+ "status": "running",
101
+ "current": None,
102
+ "done": [],
103
+ "errors": [],
104
+ "started_at": time.time(),
105
+ "ended_at": None,
106
+ })
107
+
108
+ # Import tardif pour éviter d'alourdir le boot du Space
109
+ try:
110
+ from huggingface_hub import snapshot_download
111
+ try:
112
+ # Certaines versions exposent l'exception ailleurs
113
+ from huggingface_hub.utils import HfHubHTTPError # type: ignore
114
+ except Exception: # pragma: no cover
115
+ class HfHubHTTPError(Exception):
116
+ pass
117
+ except Exception as e: # Import fatal -> on sort
118
+ _warm_log(f"ERREUR import huggingface_hub: {e}")
119
+ with _warmup_lock:
120
+ _warmup_state["status"] = "error"
121
+ _warmup_state["ended_at"] = time.time()
122
+ return
123
+
124
+ repos: List[str] = [
125
+ "facebook/sam2-hiera-large",
126
+ "lixiaowen/diffuEraser",
127
+ "runwayml/stable-diffusion-v1-5",
128
+ "stabilityai/sd-vae-ft-mse",
129
+ # Ajoute/retire selon besoin
130
+ ]
131
+
132
+ # Emplacement cache HF (persiste tant que l'espace n'est pas rebuild/GC)
133
+ HF_HOME = Path(os.getenv("HF_HOME", "/root/.cache/huggingface")).expanduser()
134
+ HF_HOME.mkdir(parents=True, exist_ok=True)
135
+
136
+ for repo in repos:
137
+ with _warmup_lock:
138
+ _warmup_state["current"] = repo
139
+ _warm_log(f"Téléchargement: {repo}")
140
+ try:
141
+ dest = HF_HOME / repo.replace("/", "__")
142
+ # skip s'il y a déjà des fichiers (warm-up one-shot)
143
+ if dest.exists() and any(dest.iterdir()):
144
+ _warm_log(f"→ Déjà présent, skip: {repo}")
145
+ with _warmup_lock:
146
+ _warmup_state["done"].append(repo)
147
+ continue
148
+
149
+ _ = snapshot_download(repo_id=repo, local_dir=str(dest), local_dir_use_symlinks=False)
150
+ _warm_log(f"OK: {repo}")
151
+ with _warmup_lock:
152
+ _warmup_state["done"].append(repo)
153
+ except Exception as e: # HfHubHTTPError ou autre
154
+ _warm_log(f"ERREUR {repo}: {e}")
155
+ with _warmup_lock:
156
+ _warmup_state["errors"].append((repo, str(e)))
157
+ finally:
158
+ # Heartbeat pour l'UI
159
+ time.sleep(0.25)
160
+
161
+ with _warmup_lock:
162
+ _warmup_state["status"] = "done" if not _warmup_state["errors"] else "error"
163
+ _warmup_state["ended_at"] = time.time()
164
+ _warm_log("Warm-up terminé.")
165
+
166
+
167
+ @app.post("/warmup/start", tags=["hub"])
168
+ def warmup_start():
169
+ with _warmup_lock:
170
+ if _warmup_state["status"] == "running":
171
+ return {"ok": True, "status": "running"}
172
+ _warmup_state["logs"].clear()
173
+ t = threading.Thread(target=_do_warmup, daemon=True)
174
+ t.start()
175
+ return {"ok": True, "status": "running"}
176
+
177
+
178
+ @app.get("/warmup/status", tags=["hub"])
179
+ def warmup_status():
180
+ with _warmup_lock:
181
+ return {
182
+ "status": _warmup_state["status"],
183
+ "current": _warmup_state["current"],
184
+ "done": list(_warmup_state["done"]),
185
+ "errors": list(_warmup_state["errors"]),
186
+ "logs": list(_warmup_state["logs"]),
187
+ "started_at": _warmup_state["started_at"],
188
+ "ended_at": _warmup_state["ended_at"],
189
+ }
190
+
191
+
192
+ @app.post("/warmup/clear", tags=["hub"])
193
+ def warmup_clear():
194
+ with _warmup_lock:
195
+ _warmup_state["logs"].clear()
196
+ _warmup_state["current"] = None
197
+ _warmup_state["done"].clear()
198
+ _warmup_state["errors"].clear()
199
+ _warmup_state["status"] = "idle"
200
+ _warmup_state["started_at"] = None
201
+ _warmup_state["ended_at"] = None
202
+ return {"ok": True}
203
+
204
+ # ------------------------------ PROXY (idem) --------------------------------
205
+ @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
206
+ async def proxy_all(full_path: str, request: Request):
207
+ base = get_backend_base().rstrip("/")
208
+ target = f"{base}/{full_path}"
209
+ qs = request.url.query
210
+ if qs:
211
+ target = f"{target}?{qs}"
212
+ body = await request.body()
213
+ headers = dict(request.headers)
214
+ headers.pop("host", None)
215
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
216
+ r = await client.request(request.method, target, headers=headers, content=body)
217
+ drop = {"content-encoding","transfer-encoding","connection",
218
+ "keep-alive","proxy-authenticate","proxy-authorization",
219
+ "te","trailers","upgrade"}
220
+ out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
221
+ return Response(content=r.content, status_code=r.status_code, headers=out_headers)
222
+
223
+ # ---------------------------- Helpers & Progress -----------------------------
224
+ progress_data: Dict[str, Dict[str, Any]] = {}
225
+
226
+ def _is_video(p: Path) -> bool:
227
+ return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
228
+
229
+ def _safe_name(name: str) -> str:
230
+ return Path(name).name.replace(" ", "_")
231
+
232
+ def _has_ffmpeg() -> bool:
233
+ return _shutil.which("ffmpeg") is not None
234
+
235
+ def _ffmpeg_scale_filter(max_w: int = 320) -> str:
236
+ return f"scale=min(iw\\,{max_w}):-2"
237
+
238
+ def _meta(video: Path):
239
+ cap = cv2.VideoCapture(str(video))
240
+ if not cap.isOpened():
241
+ print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
242
+ return None
243
+ frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
244
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
245
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
246
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
247
+ cap.release()
248
+ print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
249
+ return {"frames": frames, "fps": fps, "w": w, "h": h}
250
+
251
+ def _frame_jpg(video: Path, idx: int) -> Path:
252
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
253
+ if out.exists():
254
+ return out
255
+ if _has_ffmpeg():
256
+ m = _meta(video) or {"fps": 30.0}
257
+ fps = float(m.get("fps") or 30.0) or 30.0
258
+ t = max(0.0, float(idx) / fps)
259
+ cmd = [
260
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
261
+ "-ss", f"{t:.6f}",
262
+ "-i", str(video),
263
+ "-frames:v", "1",
264
+ "-vf", _ffmpeg_scale_filter(320),
265
+ "-q:v", "8",
266
+ str(out)
267
+ ]
268
+ try:
269
+ subprocess.run(cmd, check=True)
270
+ return out
271
+ except subprocess.CalledProcessError as e:
272
+ print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
273
+ cap = cv2.VideoCapture(str(video))
274
+ if not cap.isOpened():
275
+ print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
276
+ raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
277
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
278
+ if total <= 0:
279
+ cap.release()
280
+ print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
281
+ raise HTTPException(500, "Frame count invalide.")
282
+ idx = max(0, min(idx, total - 1))
283
+ cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
284
+ ok, img = cap.read()
285
+ cap.release()
286
+ if not ok or img is None:
287
+ print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
288
+ raise HTTPException(500, "Impossible de lire la frame demandée.")
289
+ h, w = img.shape[:2]
290
+ if w > 320:
291
+ new_w = 320
292
+ new_h = int(h * (320.0 / w)) or 1
293
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
294
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
295
+ return out
296
+
297
+ def _poster(video: Path) -> Path:
298
+ out = THUMB_DIR / f"poster_{video.stem}.jpg"
299
+ if out.exists():
300
+ return out
301
+ try:
302
+ cap = cv2.VideoCapture(str(video))
303
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
304
+ ok, img = cap.read()
305
+ cap.release()
306
+ if ok and img is not None:
307
+ cv2.imwrite(str(out), img)
308
+ except Exception as e:
309
+ print(f"[POSTER] Failed: {e}", file=sys.stdout)
310
+ return out
311
+
312
+ def _mask_file(vid: str) -> Path:
313
+ return MASK_DIR / f"{Path(vid).name}.json"
314
+
315
+ def _load_masks(vid: str) -> Dict[str, Any]:
316
+ f = _mask_file(vid)
317
+ if f.exists():
318
+ try:
319
+ return json.loads(f.read_text(encoding="utf-8"))
320
+ except Exception as e:
321
+ print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
322
+ return {"video": vid, "masks": []}
323
+
324
+ def _save_masks_atomic(vid: str, data: Dict[str, Any]):
325
+ f = _mask_file(vid)
326
+ tmp = f.with_suffix(".json.tmp")
327
+ with _mask_io_lock:
328
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
329
+ os.replace(tmp, f)
330
+
331
+ # ------------------------------ Thumbs async --------------------------------
332
+
333
+ def _gen_thumbs_background(video: Path, vid_stem: str):
334
+ progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
335
+ try:
336
+ m = _meta(video)
337
+ if not m:
338
+ progress_data[vid_stem]['logs'].append("Erreur métadonnées")
339
+ progress_data[vid_stem]['done'] = True
340
+ return
341
+ total_frames = int(m["frames"] or 0)
342
+ if total_frames <= 0:
343
+ progress_data[vid_stem]['logs'].append("Aucune frame détectée")
344
+ progress_data[vid_stem]['done'] = True
345
+ return
346
+ for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
347
+ f.unlink(missing_ok=True)
348
+ if _has_ffmpeg():
349
+ out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
350
+ cmd = [
351
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
352
+ "-i", str(video),
353
+ "-vf", _ffmpeg_scale_filter(320),
354
+ "-q:v", "8",
355
+ "-start_number", "0",
356
+ out_tpl
357
+ ]
358
+ progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
359
+ proc = subprocess.Popen(cmd)
360
+ last_report = -1
361
+ while proc.poll() is None:
362
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
363
+ percent = int(min(99, (generated / max(1, total_frames)) * 100))
364
+ progress_data[vid_stem]['percent'] = percent
365
+ if generated != last_report and generated % 50 == 0:
366
+ progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
367
+ last_report = generated
368
+ time.sleep(0.4)
369
+ proc.wait()
370
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
371
+ progress_data[vid_stem]['percent'] = 100
372
+ progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
373
+ progress_data[vid_stem]['done'] = True
374
+ print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
375
+ else:
376
+ progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
377
+ cap = cv2.VideoCapture(str(video))
378
+ if not cap.isOpened():
379
+ progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
380
+ progress_data[vid_stem]['done'] = True
381
+ return
382
+ idx = 0
383
+ last_report = -1
384
+ while True:
385
+ ok, img = cap.read()
386
+ if not ok or img is None:
387
+ break
388
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
389
+ h, w = img.shape[:2]
390
+ if w > 320:
391
+ new_w = 320
392
+ new_h = int(h * (320.0 / w)) or 1
393
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
394
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
395
+ idx += 1
396
+ if idx % 50 == 0:
397
+ progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
398
+ if idx != last_report:
399
+ progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
400
+ last_report = idx
401
+ cap.release()
402
+ progress_data[vid_stem]['percent'] = 100
403
+ progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
404
+ progress_data[vid_stem]['done'] = True
405
+ print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
406
+ except Exception as e:
407
+ progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
408
+ progress_data[vid_stem]['done'] = True
409
+
410
+ # ---------------------------------- API -------------------------------------
411
  @app.get("/", tags=["meta"])
412
  def root():
413
  return {
414
  "ok": True,
415
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup/*"],
416
  }
417
+
418
  @app.get("/health", tags=["meta"])
419
  def health():
420
  return {"status": "ok"}
421
+
422
  @app.get("/_env", tags=["meta"])
423
  def env_info():
424
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
425
+
426
  @app.get("/files", tags=["io"])
427
  def files():
428
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
429
  return {"count": len(items), "items": items}
430
+
431
  @app.get("/meta/{vid}", tags=["io"])
432
  def video_meta(vid: str):
433
  v = DATA_DIR / vid
 
437
  if not m:
438
  raise HTTPException(500, "Métadonnées indisponibles")
439
  return m
440
+
441
  @app.post("/upload", tags=["io"])
442
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
443
  ext = (Path(file.filename).suffix or ".mp4").lower()
 
458
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
459
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
460
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
461
+
462
  @app.get("/progress/{vid_stem}", tags=["io"])
463
  def progress(vid_stem: str):
464
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
465
+
466
  @app.delete("/delete/{vid}", tags=["io"])
467
  def delete_video(vid: str):
468
  v = DATA_DIR / vid
 
475
  v.unlink(missing_ok=True)
476
  print(f"[DELETE] {vid}", file=sys.stdout)
477
  return {"deleted": vid}
478
+
479
  @app.get("/frame_idx", tags=["io"])
480
  def frame_idx(vid: str, idx: int):
481
  v = DATA_DIR / vid
 
491
  except Exception as e:
492
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
493
  raise HTTPException(500, "Frame error")
494
+
495
  @app.get("/poster/{vid}", tags=["io"])
496
  def poster(vid: str):
497
  v = DATA_DIR / vid
 
501
  if p.exists():
502
  return FileResponse(str(p), media_type="image/jpeg")
503
  raise HTTPException(404, "Poster introuvable")
504
+
505
  @app.get("/window/{vid}", tags=["io"])
506
+ def window(vid: str, center: int = 0, count: int = 50, start: Optional[int] = None, end: Optional[int] = None):
507
+ """Fenêtre de timeline.
508
+ - Si start/end fournis: on respecte strictement [start, end) (portion isolée).
509
+ - Sinon: chunk classique autour de center, taille "count".
510
+ """
511
  v = DATA_DIR / vid
512
  if not v.exists():
513
  raise HTTPException(404, "Vidéo introuvable")
 
516
  raise HTTPException(500, "Métadonnées indisponibles")
517
  frames = m["frames"]
518
  count = max(3, int(count))
519
+
520
+ # Portion stricte si fournie
521
+ if start is not None and end is not None:
522
+ start = max(0, min(int(start), max(0, frames)))
523
+ end = max(start, min(int(end), frames)) # fin exclusive
524
+ n = max(0, end - start)
525
+ items = []
526
+ bust = int(time.time()*1000)
527
+ for i in range(n):
528
+ idx = start + i
529
+ url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
530
+ items.append({"i": i, "idx": idx, "url": url})
531
+ sel = max(0, min(center - start, max(0, n-1)))
532
+ return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
533
+
534
+ # Sinon, fenêtre déroulante autour de center
535
  center = max(0, min(int(center), max(0, frames-1)))
536
  if frames <= 0:
 
537
  return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
538
  if frames <= count:
539
  start = 0; sel = center; n = frames
 
546
  idx = start + i
547
  url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
548
  items.append({"i": i, "idx": idx, "url": url})
 
549
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
550
+
551
+ # --------------------------------- Masques -----------------------------------
552
  @app.post("/mask", tags=["mask"])
553
  async def save_mask(payload: Dict[str, Any] = Body(...)):
554
+ try:
555
+ vid = payload.get("vid")
556
+ if not vid:
557
+ raise HTTPException(400, "vid manquant")
558
+ pts = payload.get("points") or []
559
+ if len(pts) != 4:
560
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
561
+ data = _load_masks(vid)
562
+ m = {
563
+ "id": uuid.uuid4().hex[:10],
564
+ "time_s": float(payload.get("time_s") or 0.0),
565
+ "frame_idx": int(payload.get("frame_idx") or 0),
566
+ "shape": "rect",
567
+ "points": [float(x) for x in pts], # normalisées [0..1]
568
+ "color": payload.get("color") or "#10b981",
569
+ "note": (payload.get("note") or "").strip()
570
+ }
571
+ data.setdefault("masks", []).append(m)
572
+ _save_masks_atomic(vid, data)
573
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
574
+ return {"saved": True, "mask": m}
575
+ except HTTPException:
576
+ raise
577
+ except Exception as e:
578
+ print(f"[MASK] save ERROR: {e}", file=sys.stdout)
579
+ # Retourner une erreur JSON propre pour éviter une 500 HTML upstream
580
+ return Response(
581
+ content=json.dumps({"saved": False, "error": str(e)}),
582
+ media_type="application/json",
583
+ status_code=500,
584
+ )
585
+
586
  @app.get("/mask/{vid}", tags=["mask"])
587
  def list_masks(vid: str):
588
  return _load_masks(vid)
589
+
590
  @app.post("/mask/rename", tags=["mask"])
591
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
592
+ vid = payload.get("vid"); mid = payload.get("id"); new_note = (payload.get("note") or "").strip()
 
 
593
  if not vid or not mid:
594
  raise HTTPException(400, "vid et id requis")
595
  data = _load_masks(vid)
596
  for m in data.get("masks", []):
597
  if m.get("id") == mid:
598
  m["note"] = new_note
599
+ _save_masks_atomic(vid, data)
600
  return {"ok": True}
601
  raise HTTPException(404, "Masque introuvable")
602
+
603
  @app.post("/mask/delete", tags=["mask"])
604
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
605
+ vid = payload.get("vid"); mid = payload.get("id")
 
606
  if not vid or not mid:
607
  raise HTTPException(400, "vid et id requis")
608
  data = _load_masks(vid)
609
+ before = len(data.get("masks", []))
610
  data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
611
+ after = len(data.get("masks", []))
612
+ _save_masks_atomic(vid, data)
613
+ return {"ok": True, "removed": before - after}
614
+
615
+ # ----------------------------------- UI -------------------------------------
616
  HTML_TEMPLATE = r"""
617
  <!doctype html>
618
  <html lang="fr"><meta charset="utf-8">
619
+ <title>🎬 Video Editor</title>
620
  <style>
621
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb; --thumbH:110px}
622
  *{box-sizing:border-box}
623
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
624
  h1{margin:0 0 8px 0}
 
627
  .muted{color:var(--muted);font-size:13px}
628
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
629
  .viewer{max-width:1024px;margin:0 auto; position:relative}
630
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
631
  video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
632
  #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
633
  .timeline-container{margin-top:10px}
634
  .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%}
635
  .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
636
  .thumb:hover{transform:scale(1.05)}
637
+ .thumb img{height:var(--thumbH);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
638
  .thumb img.sel{border-color:var(--active-border)}
639
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
640
  .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}
641
  .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
 
642
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
643
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
644
  .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
 
652
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
653
  .delete-btn:hover{color:#b91c1c}
654
  #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
 
 
655
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
656
  #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
657
  #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}
658
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
659
  #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
660
  #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
 
 
 
661
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
662
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
663
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
664
+ .mini{font-size:12px;color:#6b7280}
665
+ .row-slim{display:flex;gap:8px;align-items:center}
666
  </style>
667
  <h1>🎬 Video Editor</h1>
668
  <div class="topbar card">
 
673
  </form>
674
  <span class="muted" id="msg">__MSG__</span>
675
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
676
+ <span style="flex:1"></span>
677
+ <button class="btn" id="warmStart">⚡ Warm‑up Model Hub</button>
678
+ <button class="btn" id="warmClear" title="Effacer logs warm‑up">🧹</button>
679
  </div>
680
  <div class="layout">
681
  <div>
 
692
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
693
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
694
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
695
+ <span class="mini" style="margin-left:8px">Astuce: fin = exclusive (ex: 1→55 joue 1..54)</span>
 
696
  </div>
697
  </div>
698
  <div class="card timeline-container">
699
+ <div class="row-slim" style="margin:4px 0 8px 0;gap:8px;align-items:center">
 
700
  <button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button>
701
  <button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button>
702
  <label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label>
 
706
  </div>
707
  <div id="timeline" class="timeline"></div>
708
  <div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
 
709
  <div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
710
  </div>
711
  </div>
712
  <div class="card tools">
713
+ <div class="row-slim"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div>
714
+ <div class="row-slim" style="margin-top:6px">
715
  <button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button>
716
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
717
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
718
+ <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection en cours (non enregistrée)">🧽 Effacer sélection</button>
719
  </div>
720
  <div style="margin-top:10px">
721
  <div class="muted">Couleur</div>
722
+ <div class="row-slim" id="palette" style="margin-top:6px">
723
  <div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div>
724
  <div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div>
725
  <div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div>
 
737
  <strong>Vidéos disponibles</strong>
738
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
739
  </div>
 
740
  </div>
741
  </div>
 
 
 
 
 
742
  <div id="toast"></div>
743
  <script>
744
+ // ==== ÉTAT GLOBAL ====
745
+ const serverVid = "__VID__"; const serverMsg = "__MSG__";
746
+ const statusEl = document.getElementById('msg'); statusEl.textContent = serverMsg;
747
+ const player = document.getElementById('player'); const srcEl = document.getElementById('vidsrc');
748
+ const canvas = document.getElementById('editCanvas'); const ctx = canvas.getContext('2d');
749
+ const modeLabel = document.getElementById('modeLabel'); const btnEdit = document.getElementById('btnEdit');
750
+ const btnBack = document.getElementById('btnBack'); const btnSave = document.getElementById('btnSave');
751
+ const btnClear= document.getElementById('btnClear'); const tlBox = document.getElementById('timeline');
752
+ const btnFollow = document.getElementById('btnFollow'); const btnFilterMasked = document.getElementById('btnFilterMasked');
753
+ const zoomSlider = document.getElementById('zoomSlider'); const maskedCount = document.getElementById('maskedCount');
754
+ const gotoInput = document.getElementById('gotoInput'); const gotoBtn = document.getElementById('gotoBtn');
755
+ const goFrame = document.getElementById('goFrame'); const endPortion = document.getElementById('endPortion');
756
+ const isolerBoucle = document.getElementById('isolerBoucle'); const resetFull = document.getElementById('resetFull');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  const toastWrap = document.getElementById('toast');
758
+ const warmStart = document.getElementById('warmStart'); const warmClear = document.getElementById('warmClear');
759
+
760
+ let vidName = serverVid || ''; function fileStem(name){ const i=name.lastIndexOf('.'); return i>0?name.slice(0,i):name; }
761
+ let vidStem = ''; let bustToken = Date.now();
762
+ let fps = 30, frames = 0; let currentIdx = 0; let isPaused = true;
 
 
 
 
763
  let mode = 'view';
 
764
  let color = '#10b981';
765
+ // \n Rectangles enregistrés par frame: Map<int, Array<[x1,y1,x2,y2,color]>> (coords normalisées)
766
+ const rectMap = new Map();
767
+ // Brouillon courant (non enregistré): {x1,y1,x2,y2,color} en pixels Canvas
768
+ let draft = null; let dragging=false, sx=0, sy=0;
769
+ // Divers
770
+ let maskedSet = new Set(); let timelineUrls = []; let maskedOnlyMode=false;
771
+ let viewStart = 0, viewEnd = 0; let followMode = false; let lastCenterMs=0; const CENTER_MS=150;
772
+
773
+ function showToast(msg){ const el=document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>el.remove(),2000); }
774
+
775
+ // ==== Canvas & Draw ====
776
+ function fitCanvas(){ const r=player.getBoundingClientRect(); const ctrlH=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH')); canvas.width=Math.round(r.width); canvas.height=Math.round(r.height - ctrlH); canvas.style.width=r.width+'px'; canvas.style.height=(r.height-ctrlH)+'px'; draw(); }
777
+
778
+ function draw(){ ctx.clearRect(0,0,canvas.width,canvas.height);
779
+ // Masques enregistrés sur frame courante
780
+ const arr = rectMap.get(currentIdx) || [];
781
+ for(const m of arr){ const [nx1,ny1,nx2,ny2,mc] = m; const x1=nx1*canvas.width, y1=ny1*canvas.height, x2=nx2*canvas.width, y2=ny2*canvas.height; const x=Math.min(x1,x2), y=Math.min(y1,y2); const w=Math.abs(x2-x1), h=Math.abs(y2-y1); ctx.strokeStyle=mc||'#10b981'; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(mc||'#10b981')+'28'; ctx.fillRect(x,y,w,h); }
782
+ // Brouillon
783
+ if(draft){ const x=Math.min(draft.x1,draft.x2), y=Math.min(draft.y1,draft.y2); const w=Math.abs(draft.x2-draft.x1), h=Math.abs(draft.y2-draft.y1); ctx.strokeStyle=draft.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(draft.color||color)+'28'; ctx.fillRect(x,y,w,h); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
784
  }
785
+
786
+ canvas.addEventListener('mousedown',(e)=>{ if(mode!=='edit'||!vidName) return; dragging=true; const r=canvas.getBoundingClientRect(); sx=e.clientX-r.left; sy=e.clientY-r.top; draft={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw(); });
787
+ canvas.addEventListener('mousemove',(e)=>{ if(!dragging||!draft) return; const r=canvas.getBoundingClientRect(); draft.x2=e.clientX-r.left; draft.y2=e.clientY-r.top; draw(); });
 
 
 
 
 
 
 
 
788
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
789
+
790
+ btnClear.onclick=()=>{ draft=null; draw(); };
791
  btnEdit.onclick =()=> setMode('edit');
792
  btnBack.onclick =()=> setMode('view');
793
+
794
+ function setMode(m){ mode=m; if(m==='edit'){ player.pause(); isPaused=true; player.controls=false; modeLabel.textContent='Édition'; btnEdit.style.display='none'; btnBack.style.display='inline-block'; btnSave.style.display='inline-block'; btnClear.style.display='inline-block'; canvas.style.pointerEvents='auto'; } else { player.controls=true; modeLabel.textContent='Lecture'; btnEdit.style.display='inline-block'; btnBack.style.display='none'; btnSave.style.display='none'; btnClear.style.display='none'; canvas.style.pointerEvents='none'; draft=null; } draw(); }
795
+
796
  // Palette
797
+ const palette = document.getElementById('palette');
798
+ palette.querySelectorAll('.swatch').forEach(el=>{ if(el.dataset.c===color) el.classList.add('sel'); el.onclick=()=>{ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel')); el.classList.add('sel'); color=el.dataset.c; if(draft){ draft.color=color; draw(); } }; });
799
+
800
+ // ==== Timeline (simplifié ici, logique portion/centrage côté serveur déjà OK) ====
801
+ const tlProgressFill = document.getElementById('tl-progress-fill');
802
+ let thumbEls = new Map();
803
+ function addThumb(idx){ if(thumbEls.has(idx)) return; const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx; if(maskedSet.has(idx)) wrap.classList.add('hasmask'); const img=new Image(); img.title='frame '+(idx+1); img.src=timelineUrls[idx]; if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); } img.onclick=async()=>{ goToIdx(idx,true); }; wrap.appendChild(img); const label=document.createElement('span'); label.className='thumb-label'; label.textContent=`#${idx+1}`; wrap.appendChild(label); tlBox.appendChild(wrap); thumbEls.set(idx, wrap); }
804
+
805
+ function centerThumb(idx){ const el=thumbEls.get(idx); if(!el) return; const img=el.querySelector('img'); if(!img||!img.complete||img.naturalWidth===0){ setTimeout(()=>centerThumb(idx),25); return; } tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2); }
806
+
807
+ async function renderPortion(start,end){ tlBox.innerHTML=''; thumbEls.clear(); for(let i=start;i<end;i++){ addThumb(i); } setTimeout(()=>centerThumb(currentIdx),0); }
808
+
809
+ async function renderWindow(center){ // fenêtre 50
810
+ const r=await fetch(`/window/${encodeURIComponent(vidName)}?center=${center}&count=50`); const d=await r.json(); tlBox.innerHTML=''; thumbEls.clear(); timelineUrls=[]; const bust=Date.now(); d.items.forEach(it=>{ timelineUrls[it.idx]=it.url; addThumb(it.idx); }); setTimeout(()=>centerThumb(currentIdx),0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  }
812
+
813
+ function goToIdx(idx,centerNow){ currentIdx=idx; player.currentTime = (fps>0? idx/fps : 0); tlBox.querySelectorAll('img.sel,img.sel-strong').forEach(x=>x.classList.remove('sel','sel-strong')); const el=thumbEls.get(idx); if(el){ const im=el.querySelector('img'); if(im){ im.classList.add('sel'); if(isPaused) im.classList.add('sel-strong'); } } if(centerNow) centerThumb(idx); draw(); }
814
+
815
+ // ==== Masques: load/list UI ====
816
+ const maskList = document.getElementById('maskList');
817
+ async function loadMasks(){ const r=await fetch('/mask/'+encodeURIComponent(vidName)); const d=await r.json(); const arr=d.masks||[]; rectMap.clear(); maskedSet = new Set(arr.map(m=>parseInt(m.frame_idx||0,10))); maskedCount.textContent = `(${maskedSet.size} ⭐)`; for(const m of arr){ if(m.shape==='rect'){ const fr = parseInt(m.frame_idx||0,10); if(!rectMap.has(fr)) rectMap.set(fr, []); const [x1,y1,x2,y2]=m.points; rectMap.get(fr).push([x1,y1,x2,y2,m.color||'#10b981']); } }
818
+ // Liste
819
+ if(!arr.length){ maskList.textContent='—'; } else { maskList.innerHTML=''; const ul=document.createElement('ul'); ul.className='clean'; for(const m of arr){ const li=document.createElement('li'); const fr=(parseInt(m.frame_idx||0,10)+1); const t=(m.time_s||0).toFixed(2); const col=m.color||'#10b981'; const label=(m.note||(`frame ${fr}`)).replace(/</g,'&lt;').replace(/>/g,'&gt;'); 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}</strong> — #${fr} · t=${t}s`;
820
+ const rn=document.createElement('span'); rn.className='rename-btn'; rn.textContent='✏️'; rn.title='Renommer'; rn.onclick=async()=>{ const nv=prompt('Nouveau nom du masque :', label); if(nv===null) return; await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})}); await loadMasks(); draw(); };
821
+ const del=document.createElement('span'); del.className='delete-btn'; del.textContent='❌'; del.title='Supprimer'; del.onclick=async()=>{ if(!confirm(`Supprimer masque "${label}" ?`)) return; await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})}); await loadMasks(); draw(); };
822
+ li.appendChild(rn); li.appendChild(del); ul.appendChild(li); }
823
+ maskList.appendChild(ul);
824
  }
825
+ draw();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  }
827
+
828
+ // Enregistrer le brouillon courant -> masque
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  btnSave.onclick = async ()=>{
830
+ if(!draft || !vidName){ alert('Aucune sélection.'); return; }
831
+ const defaultName = `frame ${currentIdx+1}`; const note=(prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
832
+ const nx1=Math.min(draft.x1,draft.x2)/canvas.width, ny1=Math.min(draft.y1,draft.y2)/canvas.height; const nx2=Math.max(draft.x1,draft.x2)/canvas.width, ny2=Math.max(draft.y1,draft.y2)/canvas.height;
833
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[nx1,ny1,nx2,ny2],color:draft.color||color,note};
834
+ try{ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const txt=await r.text(); if(!r.ok){ throw new Error('HTTP '+r.status+' '+txt); } const j=JSON.parse(txt); if(!j.saved){ throw new Error(j.error||'save failed'); }
835
+ // Mettre à jour rectMap (multi-rects OK)
836
+ if(!rectMap.has(currentIdx)) rectMap.set(currentIdx, []); rectMap.get(currentIdx).push([nx1,ny1,nx2,ny2,payload.color]); draft=null; await loadMasks(); showToast('Masque enregistré ✅'); draw(); }
837
+ catch(e){ alert('Échec enregistrement masque: '+e); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  };
839
+
840
+ // ==== Follow/Filter/Zoom/Goto ====
841
+ btnFollow.onclick=()=>{ followMode=!followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerThumb(currentIdx); };
842
+ btnFilterMasked.onclick=async()=>{ maskedOnlyMode=!maskedOnlyMode; btnFilterMasked.classList.toggle('toggled', maskedOnlyMode); await renderForState(); };
843
+ zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value+'px'); });
844
+ function validFrameNumber(v){ return Number.isFinite(v) && v>=1 && v<=frames; }
845
+ async function gotoFrameNum(){ const v=parseInt(gotoInput.value||'',10); if(!validFrameNumber(v)) return; isPaused=true; player.pause(); goToIdx(v-1,true); await renderForState(); centerThumb(currentIdx); }
846
+ ['click'].forEach(ev=>gotoBtn.addEventListener(ev,gotoFrameNum));
847
+
848
+ // ==== Portion ====
849
+ async function renderForState(){ if(portionStart!=null && portionEnd!=null){ await renderPortion(portionStart, portionEnd); } else { await renderWindow(currentIdx); } }
850
+ let portionStart=null, portionEnd=null; function idxToSec(i){ return (fps||30)>0? (i/fps) : 0; }
851
+
852
+ isolerBoucle.onclick = async ()=>{
853
+ const start = Math.max(0, (parseInt(goFrame.value||'1',10)-1)); const end=parseInt(endPortion.value||'',10);
854
+ if(!end || end<=start || end>frames){ alert('Portion invalide (fin > début).'); return; }
855
+ portionStart=start; portionEnd=end; isPaused=true; player.pause(); goToIdx(start,true); await renderForState(); resetFull.style.display='inline-block'; };
856
+
857
+ resetFull.onclick=async()=>{ portionStart=null; portionEnd=null; endPortion.value=''; goFrame.value=1; await renderForState(); resetFull.style.display='none'; };
858
+
859
+ // ==== Player events ====
860
+ player.addEventListener('loadedmetadata', ()=>{ fitCanvas(); draft=null; draw(); });
861
+ window.addEventListener('resize', fitCanvas);
862
+ player.addEventListener('pause', ()=>{ isPaused=true; const el=thumbEls.get(currentIdx); if(el){ const im=el.querySelector('img'); if(im){ im.classList.add('sel-strong'); } } });
863
+ player.addEventListener('play', ()=>{ isPaused=false; tlBox.querySelectorAll('img.sel-strong').forEach(x=>x.classList.remove('sel-strong')); });
864
+ player.addEventListener('timeupdate', ()=>{ const idx=Math.max(0, Math.min(frames-1, Math.round(player.currentTime*(fps||30)))); if(idx!==currentIdx){ currentIdx=idx; if(followMode && !isPaused){ const now=Date.now(); if(now-lastCenterMs>CENTER_MS){ lastCenterMs=now; centerThumb(currentIdx); } } draw(); } });
865
+
866
+ goFrame.addEventListener('change', async ()=>{ const v=Math.max(1, parseInt(goFrame.value||'1',10)); player.pause(); isPaused=true; goToIdx(v-1,true); await renderForState(); });
867
+
868
+ // ==== Warm‑up boutons ====
869
+ warmStart.onclick=async()=>{ await fetch('/warmup/start',{method:'POST'}); showToast('Warm‑up démarré'); pollWarm(); };
870
+ warmClear.onclick=async()=>{ await fetch('/warmup/clear',{method:'POST'}); showToast('Warm‑up: logs nettoyés'); };
871
+
872
+ async function pollWarm(){ const r=await fetch('/warmup/status'); const s=await r.json(); const bar = document.getElementById('tl-progress-fill'); let txt=''; if(s.status==='running'){ const cur=s.current?(' → '+s.current):''; txt = `Warm‑up: running${cur} · ok=${(s.done||[]).length} · err=${(s.errors||[]).length}`; } else if(s.status==='done'){ txt='Warm‑up: ✅ terminé'; } else if(s.status==='error'){ txt='Warm‑up: ⚠️ terminé avec erreurs'; } else { txt=''; }
873
+ statusEl.textContent = txt || serverMsg;
874
+ if(s.status==='running'){ bar.style.width = '60%'; setTimeout(pollWarm,900); } else { bar.style.width = '0%'; }
875
+ }
876
+
877
+ // ==== Boot ====
878
+ async function loadFiles(){ const r=await fetch('/files'); const d=await r.json(); const fileList=document.getElementById('fileList'); if(!d.items||!d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; } fileList.innerHTML=''; d.items.forEach(name=>{ const li=document.createElement('li'); const del=document.createElement('span'); del.className='delete-btn'; del.textContent='❌'; del.title='Supprimer'; del.onclick=async()=>{ if(!confirm(`Supprimer "${name}" ?`)) return; await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'}); loadFiles(); if(vidName===name){ vidName=''; initVideo(); } }; const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); li.appendChild(del); li.appendChild(a); fileList.appendChild(li); }); }
879
+
880
+ async function initVideo(){ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; } vidStem = fileStem(vidName); bustToken = Date.now(); const bust=Date.now(); srcEl.src='/data/'+encodeURIComponent(vidName)+'?t='+bust; player.setAttribute('poster','/poster/'+encodeURIComponent(vidName)+'?t='+bust); player.load(); fitCanvas(); statusEl.textContent='Chargement vidéo…'; try{ const r=await fetch('/meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; statusEl.textContent=`OK (${frames} frames @ ${fps.toFixed(2)} fps)`; viewStart=0; viewEnd=frames; // urls timeline (référence)
881
+ timelineUrls=[]; for(let i=0;i<frames;i++){ timelineUrls[i] = `/thumbs/f_${vidStem}_${i}.jpg?b=${bust}`; }
882
+ await loadMasks(); await renderWindow(0); } else { statusEl.textContent='Erreur meta'; } }catch{ statusEl.textContent='Erreur réseau meta'; }
883
  }
884
+
885
+ async function boot(){ await loadFiles(); if(serverVid){ vidName=serverVid; await initVideo(); } else { const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await initVideo(); } } pollWarm(); }
886
  boot();
 
 
 
 
887
  </script>
888
  </html>
889
+ """
890
+
891
+ @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
892
+ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
893
+ vid = v or ""
894
+ try:
895
+ msg = urllib.parse.unquote(msg or "")
896
+ except Exception:
897
+ pass
898
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
899
+ return HTMLResponse(content=html)