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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +178 -556
app.py CHANGED
@@ -1,391 +1,113 @@
1
- # app.py — Video Editor API (v0.8.2 + multi-rect + atomic mask save)
2
- # Ajouts sur ta v0.8.2 (sans rien casser) :
3
- # - MULTI-RECT : plusieurs rectangles par frame (UI + sauvegarde) sans écraser les précédents
4
- # - Sauvegarde ATOMIQUE des masques + verrou fichier -> évite les erreurs 500 et JSON corrompu
5
- # - Pop-up d'erreur propre (si le backend renvoie une page HTML, on affiche un message court)
6
- #
7
- # Dépendances (inchangées) :
8
- # fastapi==0.115.5
9
- # uvicorn[standard]==0.30.6
10
- # python-multipart==0.0.9
11
- # aiofiles==23.2.1
12
- # starlette==0.37.2
13
- # numpy==1.26.4
14
- # opencv-python-headless==4.10.0.84
15
- # pillow==10.4.0
16
- # huggingface_hub==0.23.5
17
- # transformers==4.44.2
18
- # torch==2.4.0
19
- # joblib==1.4.2
20
-
21
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
22
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
23
  from fastapi.staticfiles import StaticFiles
24
  from pathlib import Path
25
- from typing import Optional, Dict, Any, List
26
  import uuid, shutil, cv2, json, time, urllib.parse, sys
27
  import threading
28
  import subprocess
29
  import shutil as _shutil
30
  import os
31
  import httpx
32
-
33
- print("[BOOT] Video Editor API starting…")
34
-
35
- # --- POINTEUR DE BACKEND -----------------------------------------------------
36
- POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
37
- FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
38
- _backend_url_cache = {"url": None, "ts": 0.0}
39
-
40
- def get_backend_base() -> str:
41
- try:
42
- if POINTER_URL:
43
- now = time.time()
44
- need_refresh = (not _backend_url_cache["url"]) or (now - _backend_url_cache["ts"] > 30)
45
- if need_refresh:
46
- r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
47
- url = (r.text or "").strip()
48
- if url.startswith("http"):
49
- _backend_url_cache["url"] = url
50
- _backend_url_cache["ts"] = now
51
- else:
52
- return FALLBACK_BASE
53
- return _backend_url_cache["url"] or FALLBACK_BASE
54
- return FALLBACK_BASE
55
- except Exception:
56
- return FALLBACK_BASE
57
-
58
- print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
59
- print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
60
-
61
- app = FastAPI(title="Video Editor API", version="0.8.2-mr")
62
-
63
- # --- DATA DIRS ----------------------------------------------------------------
64
- DATA_DIR = Path("/app/data")
65
- THUMB_DIR = DATA_DIR / "_thumbs"
66
- MASK_DIR = DATA_DIR / "_masks"
67
- for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
68
- p.mkdir(parents=True, exist_ok=True)
69
-
70
- app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
71
- app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
72
-
73
- # --- PROXY VERS LE BACKEND ----------------------------------------------------
74
- @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
75
- async def proxy_all(full_path: str, request: Request):
76
- base = get_backend_base().rstrip("/")
77
- target = f"{base}/{full_path}"
78
- qs = request.url.query
79
- if qs:
80
- target = f"{target}?{qs}"
81
- body = await request.body()
82
- headers = dict(request.headers)
83
- headers.pop("host", None)
84
- async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
85
- r = await client.request(request.method, target, headers=headers, content=body)
86
- drop = {"content-encoding","transfer-encoding","connection",
87
- "keep-alive","proxy-authenticate","proxy-authorization",
88
- "te","trailers","upgrade"}
89
- out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
90
- return Response(content=r.content, status_code=r.status_code, headers=out_headers)
91
-
92
- # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
93
- progress_data: Dict[str, Dict[str, Any]] = {}
94
-
95
- # --- HELPERS ------------------------------------------------------------------
96
-
97
- def _is_video(p: Path) -> bool:
98
- return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
99
-
100
- def _safe_name(name: str) -> str:
101
- return Path(name).name.replace(" ", "_")
102
-
103
- def _has_ffmpeg() -> bool:
104
- return _shutil.which("ffmpeg") is not None
105
-
106
- def _ffmpeg_scale_filter(max_w: int = 320) -> str:
107
- return f"scale=min(iw\\,{max_w}):-2"
108
-
109
- def _meta(video: Path):
110
- cap = cv2.VideoCapture(str(video))
111
- if not cap.isOpened():
112
- print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
113
- return None
114
- frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
115
- fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
116
- w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
117
- h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
118
- cap.release()
119
- print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
120
- return {"frames": frames, "fps": fps, "w": w, "h": h}
121
-
122
- def _frame_jpg(video: Path, idx: int) -> Path:
123
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
124
- if out.exists():
125
- return out
126
- if _has_ffmpeg():
127
- m = _meta(video) or {"fps": 30.0}
128
- fps = float(m.get("fps") or 30.0) or 30.0
129
- t = max(0.0, float(idx) / fps)
130
- cmd = [
131
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
132
- "-ss", f"{t:.6f}",
133
- "-i", str(video),
134
- "-frames:v", "1",
135
- "-vf", _ffmpeg_scale_filter(320),
136
- "-q:v", "8",
137
- str(out)
138
- ]
139
- try:
140
- subprocess.run(cmd, check=True)
141
- return out
142
- except subprocess.CalledProcessError as e:
143
- print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
144
- cap = cv2.VideoCapture(str(video))
145
- if not cap.isOpened():
146
- print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
147
- raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
148
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
149
- if total <= 0:
150
- cap.release()
151
- print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
152
- raise HTTPException(500, "Frame count invalide.")
153
- idx = max(0, min(idx, total - 1))
154
- cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
155
- ok, img = cap.read()
156
- cap.release()
157
- if not ok or img is None:
158
- print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
159
- raise HTTPException(500, "Impossible de lire la frame demandée.")
160
- h, w = img.shape[:2]
161
- if w > 320:
162
- new_w = 320
163
- new_h = int(h * (320.0 / w)) or 1
164
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
165
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
166
- return out
167
-
168
- def _poster(video: Path) -> Path:
169
- out = THUMB_DIR / f"poster_{video.stem}.jpg"
170
- if out.exists():
171
- return out
172
- try:
173
- cap = cv2.VideoCapture(str(video))
174
- cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
175
- ok, img = cap.read()
176
- cap.release()
177
- if ok and img is not None:
178
- cv2.imwrite(str(out), img)
179
- except Exception as e:
180
- print(f"[POSTER] Failed: {e}", file=sys.stdout)
181
- return out
182
-
183
- def _mask_file(vid: str) -> Path:
184
- return MASK_DIR / f"{Path(vid).name}.json"
185
-
186
- def _load_masks(vid: str) -> Dict[str, Any]:
187
- f = _mask_file(vid)
188
- if f.exists():
189
- try:
190
- return json.loads(f.read_text(encoding="utf-8"))
191
- except Exception as e:
192
- print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
193
- return {"video": vid, "masks": []}
194
-
195
- # --- Sauvegarde atomique + verrou -------------------------------------------
196
- _mask_locks: Dict[str, threading.Lock] = {}
197
-
198
- def _get_mask_lock(vid: str) -> threading.Lock:
199
- key = str(_mask_file(vid))
200
- lock = _mask_locks.get(key)
201
- if not lock:
202
- lock = threading.Lock()
203
- _mask_locks[key] = lock
204
- return lock
205
-
206
- def _save_masks_atomic(vid: str, data: Dict[str, Any]):
207
- f = _mask_file(vid)
208
- tmp = f.with_suffix(f.suffix + ".tmp")
209
- txt = json.dumps(data, ensure_ascii=False, indent=2)
210
- tmp.write_text(txt, encoding="utf-8")
211
- os.replace(tmp, f) # atomic sur la plupart des FS
212
-
213
- # --- THUMBS GENERATION BG -----------------------------------------------------
214
-
215
- def _gen_thumbs_background(video: Path, vid_stem: str):
216
- progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
217
- try:
218
- m = _meta(video)
219
- if not m:
220
- progress_data[vid_stem]['logs'].append("Erreur métadonnées")
221
- progress_data[vid_stem]['done'] = True
222
- return
223
- total_frames = int(m["frames"] or 0)
224
- if total_frames <= 0:
225
- progress_data[vid_stem]['logs'].append("Aucune frame détectée")
226
- progress_data[vid_stem]['done'] = True
227
- return
228
- for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
229
- f.unlink(missing_ok=True)
230
- if _has_ffmpeg():
231
- out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
232
- cmd = [
233
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
234
- "-i", str(video),
235
- "-vf", _ffmpeg_scale_filter(320),
236
- "-q:v", "8",
237
- "-start_number", "0",
238
- out_tpl
239
- ]
240
- progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
241
- proc = subprocess.Popen(cmd)
242
- last_report = -1
243
- while proc.poll() is None:
244
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
245
- percent = int(min(99, (generated / max(1, total_frames)) * 100))
246
- progress_data[vid_stem]['percent'] = percent
247
- if generated != last_report and generated % 50 == 0:
248
- progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
249
- last_report = generated
250
- time.sleep(0.4)
251
- proc.wait()
252
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
253
- progress_data[vid_stem]['percent'] = 100
254
- progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
255
- progress_data[vid_stem]['done'] = True
256
- print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
257
- else:
258
- progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
259
- cap = cv2.VideoCapture(str(video))
260
- if not cap.isOpened():
261
- progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
262
- progress_data[vid_stem]['done'] = True
263
- return
264
- idx = 0
265
- last_report = -1
266
- while True:
267
- ok, img = cap.read()
268
- if not ok or img is None:
269
- break
270
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
271
- h, w = img.shape[:2]
272
- if w > 320:
273
- new_w = 320
274
- new_h = int(h * (320.0 / w)) or 1
275
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
276
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
277
- idx += 1
278
- if idx % 50 == 0:
279
- progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
280
- if idx != last_report:
281
- progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
282
- last_report = idx
283
- cap.release()
284
- progress_data[vid_stem]['percent'] = 100
285
- progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
286
- progress_data[vid_stem]['done'] = True
287
- print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
288
- except Exception as e:
289
- progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
290
- progress_data[vid_stem]['done'] = True
291
-
292
- # --- WARM-UP (Hub) ------------------------------------------------------------
293
- from huggingface_hub import snapshot_download
294
- try:
295
- from huggingface_hub.utils import HfHubHTTPError # 0.13+
296
- except Exception:
297
- class HfHubHTTPError(Exception):
298
- pass
299
-
300
- warmup_state: Dict[str, Any] = {
301
- "state": "idle", # idle|running|done|error
302
- "running": False,
303
- "percent": 0,
304
- "current": "",
305
- "idx": 0,
306
- "total": 0,
307
- "log": [],
308
- "started_at": None,
309
- "finished_at": None,
310
- "last_error": "",
311
- }
312
-
313
- WARMUP_MODELS: List[str] = [
314
- "facebook/sam2-hiera-large",
315
- "lixiaowen/diffuEraser",
316
- "runwayml/stable-diffusion-v1-5",
317
- "stabilityai/sd-vae-ft-mse",
318
- "ByteDance/Sa2VA-4B",
319
- "wangfuyun/PCM_Weights",
320
  ]
321
-
322
- def _append_warmup_log(msg: str):
323
- warmup_state["log"].append(msg)
324
- if len(warmup_state["log"]) > 200:
325
- warmup_state["log"] = warmup_state["log"][-200:]
326
-
327
- def _do_warmup():
328
- token = os.getenv("HF_TOKEN", None)
329
- warmup_state.update({
330
- "state": "running", "running": True, "percent": 0,
331
- "idx": 0, "total": len(WARMUP_MODELS), "current": "",
332
- "started_at": time.time(), "finished_at": None, "last_error": "",
333
- "log": []
334
- })
335
- try:
336
- total = len(WARMUP_MODELS)
337
- for i, repo in enumerate(WARMUP_MODELS):
338
- warmup_state["current"] = repo
339
- warmup_state["idx"] = i
340
- base_pct = int((i / max(1, total)) * 100)
341
- warmup_state["percent"] = min(99, base_pct)
342
- _append_warmup_log(f"➡️ Téléchargement: {repo}")
343
- try:
344
- snapshot_download(repo_id=repo, token=token)
345
- _append_warmup_log(f" OK: {repo}")
346
- except HfHubHTTPError as he:
347
- _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
348
- except Exception as e:
349
- _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
350
- warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
351
- warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
352
- except Exception as e:
353
- warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
354
- _append_warmup_log(f"❌ Warm-up erreur: {e}")
355
-
356
- @app.post("/warmup/start", tags=["warmup"])
357
- def warmup_start():
358
- if warmup_state.get("running"):
359
- return {"ok": False, "detail": "already running", "state": warmup_state}
360
- t = threading.Thread(target=_do_warmup, daemon=True)
361
- t.start()
362
- return {"ok": True, "state": warmup_state}
363
-
364
- @app.get("/warmup/status", tags=["warmup"])
365
- def warmup_status():
366
- return warmup_state
367
-
368
- # --- API ROUTES ---------------------------------------------------------------
 
 
 
 
 
 
 
 
369
  @app.get("/", tags=["meta"])
370
  def root():
371
  return {
372
  "ok": True,
373
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup/start", "/warmup/status"]
374
  }
375
-
376
  @app.get("/health", tags=["meta"])
377
  def health():
378
  return {"status": "ok"}
379
-
380
  @app.get("/_env", tags=["meta"])
381
  def env_info():
382
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
383
-
384
  @app.get("/files", tags=["io"])
385
  def files():
386
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
387
  return {"count": len(items), "items": items}
388
-
389
  @app.get("/meta/{vid}", tags=["io"])
390
  def video_meta(vid: str):
391
  v = DATA_DIR / vid
@@ -395,7 +117,6 @@ def video_meta(vid: str):
395
  if not m:
396
  raise HTTPException(500, "Métadonnées indisponibles")
397
  return m
398
-
399
  @app.post("/upload", tags=["io"])
400
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
401
  ext = (Path(file.filename).suffix or ".mp4").lower()
@@ -416,11 +137,9 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
416
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
417
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
418
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
419
-
420
  @app.get("/progress/{vid_stem}", tags=["io"])
421
  def progress(vid_stem: str):
422
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
423
-
424
  @app.delete("/delete/{vid}", tags=["io"])
425
  def delete_video(vid: str):
426
  v = DATA_DIR / vid
@@ -433,7 +152,6 @@ def delete_video(vid: str):
433
  v.unlink(missing_ok=True)
434
  print(f"[DELETE] {vid}", file=sys.stdout)
435
  return {"deleted": vid}
436
-
437
  @app.get("/frame_idx", tags=["io"])
438
  def frame_idx(vid: str, idx: int):
439
  v = DATA_DIR / vid
@@ -449,7 +167,6 @@ def frame_idx(vid: str, idx: int):
449
  except Exception as e:
450
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
451
  raise HTTPException(500, "Frame error")
452
-
453
  @app.get("/poster/{vid}", tags=["io"])
454
  def poster(vid: str):
455
  v = DATA_DIR / vid
@@ -459,7 +176,6 @@ def poster(vid: str):
459
  if p.exists():
460
  return FileResponse(str(p), media_type="image/jpeg")
461
  raise HTTPException(404, "Poster introuvable")
462
-
463
  @app.get("/window/{vid}", tags=["io"])
464
  def window(vid: str, center: int = 0, count: int = 21):
465
  v = DATA_DIR / vid
@@ -487,8 +203,7 @@ def window(vid: str, center: int = 0, count: int = 21):
487
  items.append({"i": i, "idx": idx, "url": url})
488
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
489
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
490
-
491
- # ----- Masques ---------------------------------------------------------------
492
  @app.post("/mask", tags=["mask"])
493
  async def save_mask(payload: Dict[str, Any] = Body(...)):
494
  vid = payload.get("vid")
@@ -497,27 +212,23 @@ async def save_mask(payload: Dict[str, Any] = Body(...)):
497
  pts = payload.get("points") or []
498
  if len(pts) != 4:
499
  raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
500
- lock = _get_mask_lock(vid)
501
- with lock:
502
- data = _load_masks(vid)
503
- m = {
504
- "id": uuid.uuid4().hex[:10],
505
- "time_s": float(payload.get("time_s") or 0.0),
506
- "frame_idx": int(payload.get("frame_idx") or 0),
507
- "shape": "rect",
508
- "points": [float(x) for x in pts], # normalisées [0..1]
509
- "color": payload.get("color") or "#10b981",
510
- "note": payload.get("note") or ""
511
- }
512
- data.setdefault("masks", []).append(m)
513
- _save_masks_atomic(vid, data)
514
  print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
515
  return {"saved": True, "mask": m}
516
-
517
  @app.get("/mask/{vid}", tags=["mask"])
518
  def list_masks(vid: str):
519
  return _load_masks(vid)
520
-
521
  @app.post("/mask/rename", tags=["mask"])
522
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
523
  vid = payload.get("vid")
@@ -525,29 +236,23 @@ async def rename_mask(payload: Dict[str, Any] = Body(...)):
525
  new_note = (payload.get("note") or "").strip()
526
  if not vid or not mid:
527
  raise HTTPException(400, "vid et id requis")
528
- lock = _get_mask_lock(vid)
529
- with lock:
530
- data = _load_masks(vid)
531
- for m in data.get("masks", []):
532
- if m.get("id") == mid:
533
- m["note"] = new_note
534
- _save_masks_atomic(vid, data)
535
- return {"ok": True}
536
  raise HTTPException(404, "Masque introuvable")
537
-
538
  @app.post("/mask/delete", tags=["mask"])
539
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
540
  vid = payload.get("vid")
541
  mid = payload.get("id")
542
  if not vid or not mid:
543
  raise HTTPException(400, "vid et id requis")
544
- lock = _get_mask_lock(vid)
545
- with lock:
546
- data = _load_masks(vid)
547
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
548
- _save_masks_atomic(vid, data)
549
  return {"ok": True}
550
-
551
  # --- UI ----------------------------------------------------------------------
552
  HTML_TEMPLATE = r"""
553
  <!doctype html>
@@ -560,7 +265,7 @@ HTML_TEMPLATE = r"""
560
  h1{margin:0 0 8px 0}
561
  .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
562
  .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
563
- .muted{color:#64748b;font-size:13px}
564
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
565
  .viewer{max-width:1024px;margin:0 auto; position:relative}
566
  .player-wrap{position:relative; padding-bottom: var(--controlsH);}
@@ -574,7 +279,7 @@ HTML_TEMPLATE = r"""
574
  .thumb img.sel{border-color:var(--active-border)}
575
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
576
  .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}
577
- .thumb-label{font-size:11px;color:#64748b;margin-top:2px;display:block}
578
  .timeline.filter-masked .thumb:not(.hasmask){display:none}
579
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
580
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
@@ -584,7 +289,7 @@ HTML_TEMPLATE = r"""
584
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
585
  ul.clean{list-style:none;padding-left:0;margin:6px 0}
586
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
587
- .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:#64748b;transition:color 0.2s}
588
  .rename-btn:hover{color:#2563eb}
589
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
590
  .delete-btn:hover{color:#b91c1c}
@@ -603,12 +308,6 @@ HTML_TEMPLATE = r"""
603
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
604
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
605
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
606
- /* Warmup UI */
607
- #warmupBox{display:flex;align-items:center;gap:8px}
608
- #warmup-progress{flex:1;height:8px;background:#eef2f7;border-radius:4px;overflow:hidden}
609
- #warmup-progress > div{height:100%;width:0;background:#10b981;border-radius:4px}
610
- #warmup-status{font-size:12px;color:#334155;min-width:140px}
611
- .err{color:#b91c1c}
612
  </style>
613
  <h1>🎬 Video Editor</h1>
614
  <div class="topbar card">
@@ -620,14 +319,6 @@ HTML_TEMPLATE = r"""
620
  <span class="muted" id="msg">__MSG__</span>
621
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
622
  </div>
623
- <div class="card" style="margin-bottom:10px">
624
- <div id="warmupBox">
625
- <button id="warmupBtn" class="btn">⚡ Warm-up modèles (Hub)</button>
626
- <div id="warmup-status">—</div>
627
- <div id="warmup-progress"><div id="warmup-fill"></div></div>
628
- </div>
629
- <div class="muted" id="warmup-log" style="margin-top:6px;max-height:90px;overflow:auto"></div>
630
- </div>
631
  <div class="layout">
632
  <div>
633
  <div class="viewer card" id="viewerCard">
@@ -643,7 +334,6 @@ HTML_TEMPLATE = r"""
643
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
644
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
645
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
646
- <span class="muted" style="margin-left:10px">Astuce: fin = exclusive ("55" inclut #55)</span>
647
  <span id="posInfo" style="margin-left:10px"></span>
648
  <span id="status" style="margin-left:10px;color:#2563eb"></span>
649
  </div>
@@ -739,12 +429,6 @@ const hud = document.getElementById('hud');
739
  const toastWrap = document.getElementById('toast');
740
  const gotoInput = document.getElementById('gotoInput');
741
  const gotoBtn = document.getElementById('gotoBtn');
742
- // Warmup UI
743
- const warmBtn = document.getElementById('warmupBtn');
744
- const warmStat = document.getElementById('warmup-status');
745
- const warmBar = document.getElementById('warmup-fill');
746
- const warmLog = document.getElementById('warmup-log');
747
-
748
  // State
749
  let vidName = serverVid || '';
750
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -753,19 +437,18 @@ let bustToken = Date.now();
753
  let fps = 30, frames = 0;
754
  let currentIdx = 0;
755
  let mode = 'view';
756
- let rectDraft = null, dragging=false, sx=0, sy=0; // rectangle en cours (px)
757
  let color = '#10b981';
758
- // rectMap: frame_idx -> [{nx1,ny1,nx2,ny2,color}]
759
  let rectMap = new Map();
760
  let masks = [];
761
  let maskedSet = new Set();
762
  let timelineUrls = [];
763
  let portionStart = null;
764
- let portionEnd = null; // exclusive
765
  let loopInterval = null;
766
  let chunkSize = 50;
767
  let timelineStart = 0, timelineEnd = 0;
768
- let viewRangeStart = 0, viewRangeEnd = 0;
769
  const scrollThreshold = 100;
770
  let followMode = false;
771
  let isPaused = true;
@@ -774,7 +457,6 @@ let lastCenterMs = 0;
774
  const CENTER_THROTTLE_MS = 150;
775
  const PENDING_KEY = 've_pending_masks_v1';
776
  let maskedOnlyMode = false;
777
-
778
  // Utils
779
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
780
  function ensureOverlays(){
@@ -795,28 +477,23 @@ function updateSelectedThumb(){
795
  img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
796
  }
797
  function rawCenterThumb(el){
798
- const boxRect = tlBox.getBoundingClientRect();
799
- const elRect = el.getBoundingClientRect();
800
- const current = tlBox.scrollLeft;
801
- const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
802
- const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
803
- tlBox.scrollTo({ left: target, behavior: 'auto' });
804
  }
805
  async function ensureThumbVisibleCentered(idx){
806
- for(let k=0; k<50; k++){
 
807
  const el = findThumbEl(idx);
808
  if(el){
809
  const img = el.querySelector('img');
810
  if(!img.complete || img.naturalWidth === 0){
811
- await new Promise(r=>setTimeout(r, 30));
812
  }else{
813
  rawCenterThumb(el);
814
- await new Promise(r=>requestAnimationFrame(()=>r()));
815
  updatePlayhead();
816
  return true;
817
  }
818
  }else{
819
- await new Promise(r=>setTimeout(r, 30));
820
  }
821
  }
822
  return false;
@@ -864,6 +541,7 @@ async function flushPending(){
864
  savePendingList(kept);
865
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
866
  }
 
867
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
868
  function fitCanvas(){
869
  const r=player.getBoundingClientRect();
@@ -886,7 +564,7 @@ function setMode(m){
886
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
887
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
888
  canvas.style.pointerEvents='auto';
889
- rectDraft = null; draw();
890
  }else{
891
  player.controls = true;
892
  playerWrap.classList.remove('edit-mode');
@@ -894,44 +572,31 @@ function setMode(m){
894
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
895
  btnSave.style.display='none'; btnClear.style.display='none';
896
  canvas.style.pointerEvents='none';
897
- rectDraft=null; draw();
898
  }
899
  }
900
  function draw(){
901
  ctx.clearRect(0,0,canvas.width,canvas.height);
902
- // Rectangles sauvegardés pour la frame courante (normalisés -> px)
903
- const arr = rectMap.get(currentIdx) || [];
904
- for(const r of arr){
905
- const x1 = r.nx1 * canvas.width;
906
- const y1 = r.ny1 * canvas.height;
907
- const x2 = r.nx2 * canvas.width;
908
- const y2 = r.ny2 * canvas.height;
909
- const x=Math.min(x1,x2), y=Math.min(y1,y2);
910
- const w=Math.abs(x2-x1), h=Math.abs(y2-y1);
911
- ctx.strokeStyle=r.color||'#10b981'; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
912
- ctx.fillStyle=(r.color||'#10b981')+'28'; ctx.fillRect(x,y,w,h);
913
- }
914
- // Rectangle en cours ("draft") en px
915
- if(rectDraft){
916
- const x=Math.min(rectDraft.x1,rectDraft.x2), y=Math.min(rectDraft.y1,rectDraft.y2);
917
- const w=Math.abs(rectDraft.x2-rectDraft.x1), h=Math.abs(rectDraft.y2-rectDraft.y1);
918
- ctx.strokeStyle=rectDraft.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
919
- ctx.fillStyle=(rectDraft.color||color)+'28'; ctx.fillRect(x,y,w,h);
920
  }
921
  }
922
  canvas.addEventListener('mousedown',(e)=>{
923
  if(mode!=='edit' || !vidName) return;
924
  dragging=true; const r=canvas.getBoundingClientRect();
925
  sx=e.clientX-r.left; sy=e.clientY-r.top;
926
- rectDraft={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
927
  });
928
  canvas.addEventListener('mousemove',(e)=>{
929
  if(!dragging) return;
930
  const r=canvas.getBoundingClientRect();
931
- rectDraft.x2=e.clientX-r.left; rectDraft.y2=e.clientY-r.top; draw();
932
  });
933
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
934
- btnClear.onclick=()=>{ rectDraft=null; draw(); };
935
  btnEdit.onclick =()=> setMode('edit');
936
  btnBack.onclick =()=> setMode('view');
937
  // Palette
@@ -940,7 +605,7 @@ palette.querySelectorAll('.swatch').forEach(el=>{
940
  el.onclick=()=>{
941
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
942
  el.classList.add('sel'); color=el.dataset.c;
943
- if(rectDraft){ rectDraft.color=color; draw(); }
944
  };
945
  });
946
  // === Timeline ===
@@ -963,23 +628,10 @@ async function renderTimeline(centerIdx){
963
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
964
  return;
965
  }
966
- if (portionStart!=null && portionEnd!=null){
967
- const s = Math.max(0, Math.min(portionStart, frames-1));
968
- const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
969
- tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
970
- for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
971
- timelineStart = s;
972
- timelineEnd = e;
973
- viewRangeStart = s;
974
- viewRangeEnd = e;
975
- setTimeout(async ()=>{
976
- syncTimelineWidth();
977
- updateSelectedThumb();
978
- await ensureThumbVisibleCentered(currentIdx);
979
- loadingInd.style.display='none';
980
- updatePlayhead();
981
- updatePortionOverlays();
982
- }, 0);
983
  return;
984
  }
985
  await loadWindow(centerIdx ?? currentIdx);
@@ -989,8 +641,8 @@ async function loadWindow(centerIdx){
989
  tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
990
  const rngStart = (viewRangeStart ?? 0);
991
  const rngEnd = (viewRangeEnd ?? frames);
992
- const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
993
- const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
994
  const end = Math.min(rngEnd, start + chunkSize);
995
  for(let i=start;i<end;i++){ addThumb(i,'append'); }
996
  timelineStart = start; timelineEnd = end;
@@ -1021,14 +673,14 @@ function addThumb(idx, place='append'){
1021
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
1022
  img.onclick=async ()=>{
1023
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
1024
- rectDraft = null; draw();
1025
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
1026
  };
1027
  wrap.appendChild(img);
1028
  const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
1029
  wrap.appendChild(label);
1030
  if(place==='append'){ tlBox.appendChild(wrap); }
1031
- else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
1032
  else{ tlBox.appendChild(wrap); }
1033
  thumbEls.set(idx, wrap);
1034
  }
@@ -1039,13 +691,13 @@ tlBox.addEventListener('scroll', ()=>{
1039
  return;
1040
  }
1041
  const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
1042
- if (scrollWidth - scrollLeft - clientWidth < 100 && timelineEnd < viewRangeEnd){
1043
- const newEnd = Math.min(viewRangeEnd, timelineEnd + 50);
1044
  for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
1045
  timelineEnd = newEnd;
1046
  }
1047
- if (scrollLeft < 100 && timelineStart > viewRangeStart){
1048
- const newStart = Math.max(viewRangeStart, timelineStart - 50);
1049
  for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
1050
  tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
1051
  timelineStart = newStart;
@@ -1054,21 +706,24 @@ tlBox.addEventListener('scroll', ()=>{
1054
  });
1055
  // Isoler & Boucle
1056
  isolerBoucle.onclick = async ()=>{
1057
- const start = Math.max(0, parseInt(goFrame.value || '1',10) - 1);
1058
- const endIn = parseInt(endPortion.value || '',10);
1059
- if(!endPortion.value || endIn <= (start+1) || endIn > frames){ alert('Portion invalide (fin > début)'); return; }
1060
- const endExclusive = Math.min(frames, endIn); // fin exclusive; "55" => inclut #55 (idx 54)
1061
- portionStart = start;
1062
- portionEnd = endExclusive;
1063
- viewRangeStart = start; viewRangeEnd = endExclusive;
1064
  player.pause(); isPaused = true;
1065
  currentIdx = start; player.currentTime = idxToSec(start);
1066
  await renderTimeline(currentIdx);
1067
  resetFull.style.display = 'inline-block';
1068
- if(loopInterval) clearInterval(loopInterval);
1069
- loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
1070
- updatePortionOverlays();
1071
  };
 
 
 
 
 
 
1072
  resetFull.onclick = async ()=>{
1073
  portionStart = null; portionEnd = null;
1074
  viewRangeStart = 0; viewRangeEnd = frames;
@@ -1076,8 +731,7 @@ resetFull.onclick = async ()=>{
1076
  player.pause(); isPaused = true;
1077
  await renderTimeline(currentIdx);
1078
  resetFull.style.display='none';
1079
- if(loopInterval) clearInterval(loopInterval);
1080
- updatePortionOverlays();
1081
  };
1082
  // Drag IN/OUT
1083
  function attachHandleDrag(handle, which){
@@ -1095,7 +749,7 @@ function attachHandleDrag(handle, which){
1095
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1096
  }
1097
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1098
- // Progress popup (thumbs)
1099
  async function showProgress(vidStem){
1100
  popup.style.display = 'block';
1101
  const interval = setInterval(async () => {
@@ -1103,7 +757,7 @@ async function showProgress(vidStem){
1103
  const d = await r.json();
1104
  tlProgressFill.style.width = d.percent + '%';
1105
  popupProgressFill.style.width = d.percent + '%';
1106
- popupLogs.innerHTML = (d.logs||[]).map(x=>String(x)).join('<br>');
1107
  if(d.done){
1108
  clearInterval(interval);
1109
  popup.style.display = 'none';
@@ -1145,28 +799,27 @@ player.addEventListener('loadedmetadata', async ()=>{
1145
  if(!frames || frames<=0){
1146
  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{}
1147
  }
1148
- currentIdx=0; goFrame.value=1; rectMap.clear(); rectDraft=null; draw();
1149
  });
1150
- window.addEventListener('resize', ()=>{ fitCanvas(); draw(); });
1151
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1152
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1153
  player.addEventListener('timeupdate', ()=>{
1154
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1155
  currentIdx=timeToIdx(player.currentTime);
1156
- draw();
1157
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1158
  if(followMode && !isPaused){
1159
  const now = Date.now();
1160
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1161
  }
1162
  });
1163
-
1164
  goFrame.addEventListener('change', async ()=>{
1165
  if(!vidName) return;
1166
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1167
  player.pause(); isPaused = true;
1168
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1169
- rectDraft = null; draw();
1170
  await renderTimeline(currentIdx);
1171
  await ensureThumbVisibleCentered(currentIdx);
1172
  });
@@ -1186,7 +839,6 @@ async function gotoFrameNum(){
1186
  player.pause(); isPaused = true;
1187
  currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1188
  goFrame.value = v;
1189
- rectDraft = null; draw();
1190
  await renderTimeline(currentIdx);
1191
  await ensureThumbVisibleCentered(currentIdx);
1192
  }
@@ -1231,17 +883,15 @@ async function loadMasks(){
1231
  masks=d.masks||[];
1232
  maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1233
  rectMap.clear();
1234
- // MULTI-RECT : on cumule tous les rectangles de chaque frame (coords normalisées)
1235
  masks.forEach(m=>{
1236
  if(m.shape==='rect'){
1237
- const [nx1,ny1,nx2,ny2] = m.points;
1238
- const arr = rectMap.get(m.frame_idx) || [];
1239
- arr.push({nx1,ny1,nx2,ny2,color:m.color});
1240
- rectMap.set(m.frame_idx, arr);
1241
  }
1242
  });
1243
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1244
- if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; draw(); return; }
1245
  box.innerHTML='';
1246
  const ul=document.createElement('ul'); ul.className='clean';
1247
  masks.forEach(m=>{
@@ -1257,9 +907,7 @@ async function loadMasks(){
1257
  if(nv===null) return;
1258
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1259
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1260
- let txt = await rr.text(); const ct = rr.headers.get('content-type')||'';
1261
- if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1262
- alert('Échec renommage: ' + rr.status + ' ' + txt);
1263
  }
1264
  };
1265
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
@@ -1267,67 +915,42 @@ async function loadMasks(){
1267
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
1268
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1269
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1270
- let txt = await rr.text(); const ct = rr.headers.get('content-type')||'';
1271
- if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1272
- alert('Échec suppression: ' + rr.status + ' ' + txt);
1273
  }
1274
  };
1275
  li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1276
  });
1277
  box.appendChild(ul);
1278
  loadingInd.style.display='none';
1279
- draw();
1280
  }
1281
- // Save mask (+ cache) — MULTI-RECT
1282
  btnSave.onclick = async ()=>{
1283
- if(!rectDraft || !vidName){ alert('Aucune sélection.'); return; }
1284
  const defaultName = `frame ${currentIdx+1}`;
1285
  const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1286
- const nx1 = Math.min(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1287
- const ny1 = Math.min(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1288
- const nx2 = Math.max(rectDraft.x1,rectDraft.x2) / canvas.clientWidth;
1289
- const ny2 = Math.max(rectDraft.y1,rectDraft.y2) / canvas.clientHeight;
1290
- const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[nx1,ny1,nx2,ny2],color:rectDraft.color||color,note:note};
 
1291
  addPending(payload);
1292
  try{
1293
  const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1294
  if(r.ok){
1295
  const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1296
  savePendingList(lst);
1297
- const arr = rectMap.get(currentIdx) || [];
1298
- arr.push({nx1,ny1,nx2,ny2,color:payload.color});
1299
- rectMap.set(currentIdx, arr);
1300
- rectDraft = null;
1301
  await loadMasks(); await renderTimeline(currentIdx);
1302
  showToast('Masque enregistré ✅');
1303
  } else {
1304
- let txt = await r.text(); const ct = r.headers.get('content-type')||'';
1305
- if(ct.includes('text/html') || txt.startsWith('<!DOCTYPE')) txt = 'Erreur côté serveur.';
1306
  alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1307
  }
1308
  }catch(e){
1309
  alert('Erreur réseau lors de l’enregistrement du masque.');
1310
  }
1311
  };
1312
- // Warm-up button
1313
- warmBtn.onclick = async ()=>{
1314
- try{ await fetch('/warmup/start',{method:'POST'}); }catch{}
1315
- const poll = async ()=>{
1316
- try{
1317
- const r = await fetch('/warmup/status');
1318
- const st = await r.json();
1319
- warmBar.style.width = (st.percent||0) + '%';
1320
- let txt = (st.state||'idle');
1321
- if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1322
- if(st.state==='error'){ txt += ' · erreur'; }
1323
- warmStat.textContent = txt;
1324
- const lines = (st.log||[]).slice(-6);
1325
- warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
1326
- if(st.state==='done' || st.state==='error') return; else setTimeout(poll, 1000);
1327
- }catch{ setTimeout(poll, 1500); }
1328
- };
1329
- poll();
1330
- };
1331
  // Boot
1332
  async function boot(){
1333
  const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
@@ -1343,7 +966,6 @@ document.head.appendChild(style);
1343
  </script>
1344
  </html>
1345
  """
1346
-
1347
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1348
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1349
  vid = v or ""
@@ -1352,4 +974,4 @@ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1352
  except Exception:
1353
  pass
1354
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1355
- return HTMLResponse(content=html)
 
1
+ # app.py — Video Editor API (v0.8.2)
2
+ # Fixes:
3
+ # - Portion rendering shows full selected range (e.g., 1→55, 70→480, 8→88)
4
+ # - Accurate centering for "Aller à #" (no offset drift)
5
+ # - Robust Warm-up (Hub) with safe imports + error surfacing (no silent crash)
6
+ # Base: compatible with previous v0.5.9 routes and UI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
8
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
9
  from fastapi.staticfiles import StaticFiles
10
  from pathlib import Path
11
+ from typing import Optional, Dict, Any
12
  import uuid, shutil, cv2, json, time, urllib.parse, sys
13
  import threading
14
  import subprocess
15
  import shutil as _shutil
16
  import os
17
  import httpx
18
+ import huggingface_hub as hf
19
+ from joblib import Parallel, delayed
20
+ # --- POINTEUR (inchangé) ---
21
+ # ... (code pointeur)
22
+ print("[BOOT] Starting...")
23
+ app = FastAPI(title="Video Editor API", version="0.8.2")
24
+ # ... (DATA_DIR, mounts inchangés)
25
+ # --- Chargement Modèles au Boot ---
26
+ def load_model(repo_id):
27
+ path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
28
+ if not path.exists() or not any(path.iterdir()):
29
+ print(f"[BOOT] Downloading {repo_id}...")
30
+ hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
31
+ (path / "loaded.ok").touch()
32
+ # Symlink exemples
33
+ if "sam2" in repo_id: shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
34
+ # Ajoute pour autres
35
+ models = [
36
+ "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
37
+ "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ]
39
+ Parallel(n_jobs=4)(delayed(load_model)(m) for m in models)
40
+ # ProPainter wget
41
+ PROP = Path("/app/propainter")
42
+ PROP.mkdir(exist_ok=True)
43
+ def wget(url, dest):
44
+ if not (dest / url.split("/")[-1]).exists():
45
+ subprocess.run(["wget", "-q", url, "-P", str(dest)])
46
+ wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth", PROP)
47
+ wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth", PROP)
48
+ wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth", PROP)
49
+ print("[BOOT] Models ready.")
50
+ # --- PROXY (inchangé) ---
51
+ # ... (code proxy)
52
+ # Helpers + is_gpu() (inchangé)
53
+ # API (Ajouts IA stubs + Nouveaux pour Améliorations)
54
+ @app.post("/mask/ai")
55
+ async def mask_ai(payload: Dict[str, Any] = Body(...)):
56
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
57
+ # TODO: Impl SAM2
58
+ return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
59
+ @app.post("/inpaint")
60
+ async def inpaint(payload: Dict[str, Any] = Body(...)):
61
+ if not is_gpu(): raise HTTPException(503, "Switch GPU.")
62
+ # TODO: Impl DiffuEraser, update progress_ia
63
+ return {"ok": True, "preview": "/data/preview.mp4"}
64
+ @app.get("/estimate")
65
+ def estimate(vid: str, masks_count: int):
66
+ # TODO: Calcul simple (frames * masks * facteur GPU)
67
+ return {"time_min": 5, "vram_gb": 4}
68
+ @app.get("/progress_ia")
69
+ def progress_ia(vid: str):
70
+ # TODO: Retourne % et logs (e.g., {"percent": 50, "log": "Frame 25/50"})
71
+ return {"percent": 0, "log": "En cours..."}
72
+ # ... (autres routes inchangées, étend /mask pour multi-masques array)
73
+ # UI (Ajoute undo/redo boutons, preview popup, tutoriel , auto-save JS, feedback barre)
74
+ HTML_TEMPLATE = r"""
75
+ Video Editor
76
+ # ... (topbar, layout inchangés)
77
+ # ... (modes, boutons inchangés)
78
+ ↩️ Undo
79
+ ↪️ Redo
80
+ # ... (palette, masques liste pour multi)
81
+ Progression IA:
82
+ Tutoriel (cliquer pour masquer)
83
+ 1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.
84
+ """
85
+ # ... (ui func inchangée)
86
+ @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
87
+ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
88
+ vid = v or ""
89
+ try:
90
+ msg = urllib.parse.unquote(msg or "")
91
+ except Exception:
92
+ pass
93
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
94
+ return HTMLResponse(content=html)
95
  @app.get("/", tags=["meta"])
96
  def root():
97
  return {
98
  "ok": True,
99
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
100
  }
 
101
  @app.get("/health", tags=["meta"])
102
  def health():
103
  return {"status": "ok"}
 
104
  @app.get("/_env", tags=["meta"])
105
  def env_info():
106
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
 
107
  @app.get("/files", tags=["io"])
108
  def files():
109
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
110
  return {"count": len(items), "items": items}
 
111
  @app.get("/meta/{vid}", tags=["io"])
112
  def video_meta(vid: str):
113
  v = DATA_DIR / vid
 
117
  if not m:
118
  raise HTTPException(500, "Métadonnées indisponibles")
119
  return m
 
120
  @app.post("/upload", tags=["io"])
121
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
122
  ext = (Path(file.filename).suffix or ".mp4").lower()
 
137
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
138
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
139
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
 
140
  @app.get("/progress/{vid_stem}", tags=["io"])
141
  def progress(vid_stem: str):
142
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
 
143
  @app.delete("/delete/{vid}", tags=["io"])
144
  def delete_video(vid: str):
145
  v = DATA_DIR / vid
 
152
  v.unlink(missing_ok=True)
153
  print(f"[DELETE] {vid}", file=sys.stdout)
154
  return {"deleted": vid}
 
155
  @app.get("/frame_idx", tags=["io"])
156
  def frame_idx(vid: str, idx: int):
157
  v = DATA_DIR / vid
 
167
  except Exception as e:
168
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
169
  raise HTTPException(500, "Frame error")
 
170
  @app.get("/poster/{vid}", tags=["io"])
171
  def poster(vid: str):
172
  v = DATA_DIR / vid
 
176
  if p.exists():
177
  return FileResponse(str(p), media_type="image/jpeg")
178
  raise HTTPException(404, "Poster introuvable")
 
179
  @app.get("/window/{vid}", tags=["io"])
180
  def window(vid: str, center: int = 0, count: int = 21):
181
  v = DATA_DIR / vid
 
203
  items.append({"i": i, "idx": idx, "url": url})
204
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
205
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
206
+ # ----- Masques -----
 
207
  @app.post("/mask", tags=["mask"])
208
  async def save_mask(payload: Dict[str, Any] = Body(...)):
209
  vid = payload.get("vid")
 
212
  pts = payload.get("points") or []
213
  if len(pts) != 4:
214
  raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
215
+ data = _load_masks(vid)
216
+ m = {
217
+ "id": uuid.uuid4().hex[:10],
218
+ "time_s": float(payload.get("time_s") or 0.0),
219
+ "frame_idx": int(payload.get("frame_idx") or 0),
220
+ "shape": "rect",
221
+ "points": [float(x) for x in pts],
222
+ "color": payload.get("color") or "#10b981",
223
+ "note": payload.get("note") or ""
224
+ }
225
+ data.setdefault("masks", []).append(m)
226
+ _save_masks(vid, data)
 
 
227
  print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
228
  return {"saved": True, "mask": m}
 
229
  @app.get("/mask/{vid}", tags=["mask"])
230
  def list_masks(vid: str):
231
  return _load_masks(vid)
 
232
  @app.post("/mask/rename", tags=["mask"])
233
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
234
  vid = payload.get("vid")
 
236
  new_note = (payload.get("note") or "").strip()
237
  if not vid or not mid:
238
  raise HTTPException(400, "vid et id requis")
239
+ data = _load_masks(vid)
240
+ for m in data.get("masks", []):
241
+ if m.get("id") == mid:
242
+ m["note"] = new_note
243
+ _save_masks(vid, data)
244
+ return {"ok": True}
 
 
245
  raise HTTPException(404, "Masque introuvable")
 
246
  @app.post("/mask/delete", tags=["mask"])
247
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
248
  vid = payload.get("vid")
249
  mid = payload.get("id")
250
  if not vid or not mid:
251
  raise HTTPException(400, "vid et id requis")
252
+ data = _load_masks(vid)
253
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
254
+ _save_masks(vid, data)
 
 
255
  return {"ok": True}
 
256
  # --- UI ----------------------------------------------------------------------
257
  HTML_TEMPLATE = r"""
258
  <!doctype html>
 
265
  h1{margin:0 0 8px 0}
266
  .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
267
  .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
268
+ .muted{color:var(--muted);font-size:13px}
269
  .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
270
  .viewer{max-width:1024px;margin:0 auto; position:relative}
271
  .player-wrap{position:relative; padding-bottom: var(--controlsH);}
 
279
  .thumb img.sel{border-color:var(--active-border)}
280
  .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
281
  .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}
282
+ .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
283
  .timeline.filter-masked .thumb:not(.hasmask){display:none}
284
  .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
285
  .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
 
289
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
290
  ul.clean{list-style:none;padding-left:0;margin:6px 0}
291
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
292
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
293
  .rename-btn:hover{color:#2563eb}
294
  .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
295
  .delete-btn:hover{color:#b91c1c}
 
308
  #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
309
  #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
310
  .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
 
 
 
 
 
 
311
  </style>
312
  <h1>🎬 Video Editor</h1>
313
  <div class="topbar card">
 
319
  <span class="muted" id="msg">__MSG__</span>
320
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
321
  </div>
 
 
 
 
 
 
 
 
322
  <div class="layout">
323
  <div>
324
  <div class="viewer card" id="viewerCard">
 
334
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
335
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
336
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
 
337
  <span id="posInfo" style="margin-left:10px"></span>
338
  <span id="status" style="margin-left:10px;color:#2563eb"></span>
339
  </div>
 
429
  const toastWrap = document.getElementById('toast');
430
  const gotoInput = document.getElementById('gotoInput');
431
  const gotoBtn = document.getElementById('gotoBtn');
 
 
 
 
 
 
432
  // State
433
  let vidName = serverVid || '';
434
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
 
437
  let fps = 30, frames = 0;
438
  let currentIdx = 0;
439
  let mode = 'view';
440
+ let rect = null, dragging=false, sx=0, sy=0;
441
  let color = '#10b981';
 
442
  let rectMap = new Map();
443
  let masks = [];
444
  let maskedSet = new Set();
445
  let timelineUrls = [];
446
  let portionStart = null;
447
+ let portionEnd = null;
448
  let loopInterval = null;
449
  let chunkSize = 50;
450
  let timelineStart = 0, timelineEnd = 0;
451
+ let viewRangeStart = 0, viewRangeEnd = 0; // portée visible (portion ou full)
452
  const scrollThreshold = 100;
453
  let followMode = false;
454
  let isPaused = true;
 
457
  const CENTER_THROTTLE_MS = 150;
458
  const PENDING_KEY = 've_pending_masks_v1';
459
  let maskedOnlyMode = false;
 
460
  // Utils
461
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
462
  function ensureOverlays(){
 
477
  img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
478
  }
479
  function rawCenterThumb(el){
480
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
 
 
 
 
 
481
  }
482
  async function ensureThumbVisibleCentered(idx){
483
+ // Attendre que l’élément + image soient prêts avant de centrer
484
+ for(let k=0; k<40; k++){
485
  const el = findThumbEl(idx);
486
  if(el){
487
  const img = el.querySelector('img');
488
  if(!img.complete || img.naturalWidth === 0){
489
+ await new Promise(r=>setTimeout(r, 25));
490
  }else{
491
  rawCenterThumb(el);
 
492
  updatePlayhead();
493
  return true;
494
  }
495
  }else{
496
+ await new Promise(r=>setTimeout(r, 25));
497
  }
498
  }
499
  return false;
 
541
  savePendingList(kept);
542
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
543
  }
544
+ // Layout
545
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
546
  function fitCanvas(){
547
  const r=player.getBoundingClientRect();
 
564
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
565
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
566
  canvas.style.pointerEvents='auto';
567
+ rect = rectMap.get(currentIdx) || null; draw();
568
  }else{
569
  player.controls = true;
570
  playerWrap.classList.remove('edit-mode');
 
572
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
573
  btnSave.style.display='none'; btnClear.style.display='none';
574
  canvas.style.pointerEvents='none';
575
+ rect=null; draw();
576
  }
577
  }
578
  function draw(){
579
  ctx.clearRect(0,0,canvas.width,canvas.height);
580
+ if(rect){
581
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
582
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
583
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
584
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  }
586
  }
587
  canvas.addEventListener('mousedown',(e)=>{
588
  if(mode!=='edit' || !vidName) return;
589
  dragging=true; const r=canvas.getBoundingClientRect();
590
  sx=e.clientX-r.left; sy=e.clientY-r.top;
591
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
592
  });
593
  canvas.addEventListener('mousemove',(e)=>{
594
  if(!dragging) return;
595
  const r=canvas.getBoundingClientRect();
596
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
597
  });
598
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
599
+ btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
600
  btnEdit.onclick =()=> setMode('edit');
601
  btnBack.onclick =()=> setMode('view');
602
  // Palette
 
605
  el.onclick=()=>{
606
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
607
  el.classList.add('sel'); color=el.dataset.c;
608
+ if(rect){ rect.color=color; draw(); }
609
  };
610
  });
611
  // === Timeline ===
 
628
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
629
  return;
630
  }
631
+ if(portionStart!=null && portionEnd!=null){
632
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
633
+ for(let i=s;i<e;i++){ addThumb(i,'append'); }
634
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  return;
636
  }
637
  await loadWindow(centerIdx ?? currentIdx);
 
641
  tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
642
  const rngStart = (viewRangeStart ?? 0);
643
  const rngEnd = (viewRangeEnd ?? frames);
644
+ const mid = Math.max(rngStart, Math.min(centerIdx, max(rngStart, rngEnd-1)));
645
+ const start = Math.max(rngStart, min(mid - Math.floor(chunkSize/2), max(rngStart, rngEnd - chunkSize)));
646
  const end = Math.min(rngEnd, start + chunkSize);
647
  for(let i=start;i<end;i++){ addThumb(i,'append'); }
648
  timelineStart = start; timelineEnd = end;
 
673
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
674
  img.onclick=async ()=>{
675
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
676
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
677
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
678
  };
679
  wrap.appendChild(img);
680
  const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
681
  wrap.appendChild(label);
682
  if(place==='append'){ tlBox.appendChild(wrap); }
683
+ else if(place='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
684
  else{ tlBox.appendChild(wrap); }
685
  thumbEls.set(idx, wrap);
686
  }
 
691
  return;
692
  }
693
  const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
694
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
695
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
696
  for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
697
  timelineEnd = newEnd;
698
  }
699
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
700
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
701
  for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
702
  tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
703
  timelineStart = newStart;
 
706
  });
707
  // Isoler & Boucle
708
  isolerBoucle.onclick = async ()=>{
709
+ const start = parseInt(goFrame.value || '1',10) - 1;
710
+ const end = parseInt(endPortion.value || '',10);
711
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
712
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
713
+ portionStart = start; portionEnd = end;
714
+ viewRangeStart = start; viewRangeEnd = end;
 
715
  player.pause(); isPaused = true;
716
  currentIdx = start; player.currentTime = idxToSec(start);
717
  await renderTimeline(currentIdx);
718
  resetFull.style.display = 'inline-block';
719
+ startLoop(); updatePortionOverlays();
 
 
720
  };
721
+ function startLoop(){
722
+ if(loopInterval) clearInterval(loopInterval);
723
+ if(portionEnd != null){
724
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
725
+ }
726
+ }
727
  resetFull.onclick = async ()=>{
728
  portionStart = null; portionEnd = null;
729
  viewRangeStart = 0; viewRangeEnd = frames;
 
731
  player.pause(); isPaused = true;
732
  await renderTimeline(currentIdx);
733
  resetFull.style.display='none';
734
+ clearInterval(loopInterval); updatePortionOverlays();
 
735
  };
736
  // Drag IN/OUT
737
  function attachHandleDrag(handle, which){
 
749
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
750
  }
751
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
752
+ // Progress popup
753
  async function showProgress(vidStem){
754
  popup.style.display = 'block';
755
  const interval = setInterval(async () => {
 
757
  const d = await r.json();
758
  tlProgressFill.style.width = d.percent + '%';
759
  popupProgressFill.style.width = d.percent + '%';
760
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
761
  if(d.done){
762
  clearInterval(interval);
763
  popup.style.display = 'none';
 
799
  if(!frames || frames<=0){
800
  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{}
801
  }
802
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
803
  });
804
+ window.addEventListener('resize', ()=>{ fitCanvas(); });
805
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
806
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
807
  player.addEventListener('timeupdate', ()=>{
808
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
809
  currentIdx=timeToIdx(player.currentTime);
810
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
811
  updateHUD(); updateSelectedThumb(); updatePlayhead();
812
  if(followMode && !isPaused){
813
  const now = Date.now();
814
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
815
  }
816
  });
 
817
  goFrame.addEventListener('change', async ()=>{
818
  if(!vidName) return;
819
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
820
  player.pause(); isPaused = true;
821
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
822
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
823
  await renderTimeline(currentIdx);
824
  await ensureThumbVisibleCentered(currentIdx);
825
  });
 
839
  player.pause(); isPaused = true;
840
  currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
841
  goFrame.value = v;
 
842
  await renderTimeline(currentIdx);
843
  await ensureThumbVisibleCentered(currentIdx);
844
  }
 
883
  masks=d.masks||[];
884
  maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
885
  rectMap.clear();
 
886
  masks.forEach(m=>{
887
  if(m.shape==='rect'){
888
+ const [x1,y1,x2,y2] = m.points;
889
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
890
+ rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
 
891
  }
892
  });
893
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
894
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
895
  box.innerHTML='';
896
  const ul=document.createElement('ul'); ul.className='clean';
897
  masks.forEach(m=>{
 
907
  if(nv===null) return;
908
  const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
909
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
910
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
 
 
911
  }
912
  };
913
  const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
 
915
  if(!confirm(`Supprimer masque "${label}" ?`)) return;
916
  const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
917
  if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
918
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
 
 
919
  }
920
  };
921
  li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
922
  });
923
  box.appendChild(ul);
924
  loadingInd.style.display='none';
 
925
  }
926
+ // Save mask (+ cache)
927
  btnSave.onclick = async ()=>{
928
+ if(!rect || !vidName){ alert('Aucune sélection.'); return; }
929
  const defaultName = `frame ${currentIdx+1}`;
930
  const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
931
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
932
+ const x=Math.min(rect.x1,rect.x2)/normW;
933
+ const y=Math.min(rect.y1,rect.y2)/normH;
934
+ const w=Math.abs(rect.x2-rect.x1)/normW;
935
+ const h=Math.abs(rect.y2-rect.y1)/normH;
936
+ 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};
937
  addPending(payload);
938
  try{
939
  const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
940
  if(r.ok){
941
  const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
942
  savePendingList(lst);
943
+ rectMap.set(currentIdx,{...rect});
 
 
 
944
  await loadMasks(); await renderTimeline(currentIdx);
945
  showToast('Masque enregistré ✅');
946
  } else {
947
+ const txt = await r.text();
 
948
  alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
949
  }
950
  }catch(e){
951
  alert('Erreur réseau lors de l’enregistrement du masque.');
952
  }
953
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  // Boot
955
  async function boot(){
956
  const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
 
966
  </script>
967
  </html>
968
  """
 
969
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
970
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
971
  vid = v or ""
 
974
  except Exception:
975
  pass
976
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
977
+ return HTMLResponse(content=html)