FABLESLIP commited on
Commit
e02cbea
·
verified ·
1 Parent(s): fd84955

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +405 -386
app.py CHANGED
@@ -1,5 +1,10 @@
1
- # app.py — Video Editor API (v0.8.1)
2
- # v0.8.1: Fix bugs (timeline/frames/progress/import), prefix pour pointer, + Améliorations intégrées
 
 
 
 
 
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
@@ -11,17 +16,19 @@ 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
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
18
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
19
  _backend_url_cache = {"url": None, "ts": 0.0}
 
20
  def get_backend_base() -> str:
21
  try:
22
  if POINTER_URL:
23
  now = time.time()
24
- need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
25
  if need_refresh:
26
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
27
  url = (r.text or "").strip()
@@ -34,18 +41,23 @@ def get_backend_base() -> str:
34
  return FALLBACK_BASE
35
  except Exception:
36
  return FALLBACK_BASE
37
- print("[BOOT] Video Editor API starting…")
38
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
39
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
40
- app = FastAPI(title="Video Editor API", version="0.8.1")
 
 
 
41
  DATA_DIR = Path("/app/data")
42
  THUMB_DIR = DATA_DIR / "_thumbs"
43
  MASK_DIR = DATA_DIR / "_masks"
44
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
45
  p.mkdir(parents=True, exist_ok=True)
 
46
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
47
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
48
- # --- PROXY (inchangé) ---
 
49
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
50
  async def proxy_all(full_path: str, request: Request):
51
  base = get_backend_base().rstrip("/")
@@ -63,15 +75,24 @@ async def proxy_all(full_path: str, request: Request):
63
  "te","trailers","upgrade"}
64
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
65
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
66
- # --- Helpers (inchangé) ---
 
 
 
 
 
67
  def _is_video(p: Path) -> bool:
68
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
 
69
  def _safe_name(name: str) -> str:
70
  return Path(name).name.replace(" ", "_")
 
71
  def _has_ffmpeg() -> bool:
72
  return _shutil.which("ffmpeg") is not None
 
73
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
74
  return f"scale=min(iw\\,{max_w}):-2"
 
75
  def _meta(video: Path):
76
  cap = cv2.VideoCapture(str(video))
77
  if not cap.isOpened():
@@ -84,6 +105,7 @@ def _meta(video: Path):
84
  cap.release()
85
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
86
  return {"frames": frames, "fps": fps, "w": w, "h": h}
 
87
  def _frame_jpg(video: Path, idx: int) -> Path:
88
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
89
  if out.exists():
@@ -129,6 +151,7 @@ def _frame_jpg(video: Path, idx: int) -> Path:
129
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
130
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
131
  return out
 
132
  def _poster(video: Path) -> Path:
133
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
134
  if out.exists():
@@ -143,8 +166,10 @@ def _poster(video: Path) -> Path:
143
  except Exception as e:
144
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
145
  return out
 
146
  def _mask_file(vid: str) -> Path:
147
  return MASK_DIR / f"{Path(vid).name}.json"
 
148
  def _load_masks(vid: str) -> Dict[str, Any]:
149
  f = _mask_file(vid)
150
  if f.exists():
@@ -153,8 +178,12 @@ def _load_masks(vid: str) -> Dict[str, Any]:
153
  except Exception as e:
154
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
155
  return {"video": vid, "masks": []}
 
156
  def _save_masks(vid: str, data: Dict[str, Any]):
157
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
158
  def _gen_thumbs_background(video: Path, vid_stem: str):
159
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
160
  try:
@@ -231,161 +260,106 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
231
  except Exception as e:
232
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
233
  progress_data[vid_stem]['done'] = True
234
- def is_gpu():
235
- return False
236
- # --- Chargement Modèles (inchangé) ---
237
- models = [
238
- "facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
239
- "runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
240
- ]
241
- PROP_URLS = [
242
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth",
243
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth",
244
- "https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  ]
246
- def load_model(repo_id, retry=3):
247
- path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
248
- for attempt in range(retry):
249
- try:
250
- if not path.exists() or not any(path.iterdir()):
251
- warmup_progress['logs'].append(f"Downloading {repo_id} (attempt {attempt+1})...")
252
- hf.snapshot_download(repo_id=repo_id, local_dir=str(path))
253
- (path / "loaded.ok").touch()
254
- if "sam2" in repo_id:
255
- shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
256
- warmup_progress['logs'].append(f"{repo_id} ready.")
257
- return True
258
- except Exception as e:
259
- warmup_progress['logs'].append(f"Error {repo_id}: {e} (retrying...)")
260
- return False
261
- def wget_prop(url, dest, retry=3):
262
- fname = url.split("/")[-1]
263
- out = dest / fname
264
- for attempt in range(retry):
265
- try:
266
- if not out.exists():
267
- warmup_progress['logs'].append(f"Downloading {fname} (attempt {attempt+1})...")
268
- subprocess.run(["wget", "-q", url, "-P", str(dest)], check=True)
269
- warmup_progress['logs'].append(f"{fname} ready.")
270
- return True
271
- except Exception as e:
272
- warmup_progress['logs'].append(f"Error {fname}: {e} (retrying...)")
273
- return False
274
- @app.post("/warmup")
275
- def warmup():
276
- if warmup_progress['in_progress']:
277
- raise HTTPException(400, "Warm-up already in progress.")
278
- warmup_progress['in_progress'] = True
279
- warmup_progress['percent'] = 0
280
- warmup_progress['logs'] = ["Warm-up started."]
281
- threading.Thread(target=_run_warmup, daemon=True).start()
282
- return {"ok": True}
283
- def _run_warmup():
284
- total = len(models) + len(PROP_URLS)
285
- done = 0
286
- for m in models:
287
- if load_model(m):
288
- done += 1
289
- warmup_progress['percent'] = int((done / total) * 100)
290
- else:
291
- warmup_progress['logs'].append(f"Failed {m} after retries.")
292
- PROP = Path("/app/propainter")
293
- PROP.mkdir(exist_ok=True)
294
- for url in PROP_URLS:
295
- if wget_prop(url, PROP):
296
- done += 1
297
- warmup_progress['percent'] = int((done / total) * 100)
298
- else:
299
- warmup_progress['logs'].append(f"Failed {url.split('/')[-1]} after retries.")
300
- warmup_progress['done'] = True
301
- warmup_progress['in_progress'] = False
302
- warmup_progress['logs'].append("Warm-up complete.")
303
- @app.get("/warmup_progress")
304
- def get_warmup_progress():
305
- return warmup_progress
306
- # --- IA stubs (inchangé) ---
307
- @app.post("/mask/ai")
308
- async def mask_ai(payload: Dict[str, Any] = Body(...)):
309
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
310
- return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
311
- @app.post("/inpaint")
312
- async def inpaint(payload: Dict[str, Any] = Body(...)):
313
- if not is_gpu(): raise HTTPException(503, "Switch GPU.")
314
- return {"ok": True, "preview": "/data/preview.mp4"}
315
- @app.get("/estimate")
316
- def estimate(vid: str, masks_count: int):
317
- return {"time_min": 5, "vram_gb": 4} if is_gpu() else {"time_min": "N/A (CPU)", "vram_gb": "N/A (CPU)"}
318
- @app.get("/progress_ia")
319
- def progress_ia(vid: str):
320
- return {"percent": 0, "log": "En attente..."}
321
- # --- Masques (inchangé) ---
322
- @app.post("/mask", tags=["mask"])
323
- async def save_mask(payload: Dict[str, Any] = Body(...)):
324
- vid = payload.get("vid")
325
- if not vid:
326
- raise HTTPException(400, "vid manquant")
327
- pts = payload.get("points") or []
328
- if len(pts) != 4:
329
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
330
- data = _load_masks(vid)
331
- m = {
332
- "id": uuid.uuid4().hex[:10],
333
- "time_s": float(payload.get("time_s") or 0.0),
334
- "frame_idx": int(payload.get("frame_idx") or 0),
335
- "shape": "rect",
336
- "points": [float(x) for x in pts],
337
- "color": payload.get("color") or "#10b981",
338
- "note": payload.get("note") or ""
339
- }
340
- data.setdefault("masks", []).append(m)
341
- _save_masks(vid, data)
342
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
343
- return {"saved": True, "mask": m}
344
- @app.get("/mask/{vid}", tags=["mask"])
345
- def list_masks(vid: str, frame_idx: Optional[int] = None):
346
- data = _load_masks(vid)
347
- if frame_idx is not None:
348
- return [m for m in data["masks"] if m["frame_idx"] == frame_idx]
349
- return data
350
- @app.post("/mask/rename", tags=["mask"])
351
- async def rename_mask(payload: Dict[str, Any] = Body(...)):
352
- vid = payload.get("vid")
353
- mid = payload.get("id")
354
- new_note = (payload.get("note") or "").strip()
355
- if not vid or not mid:
356
- raise HTTPException(400, "vid et id requis")
357
- data = _load_masks(vid)
358
- for m in data.get("masks", []):
359
- if m.get("id") == mid:
360
- m["note"] = new_note
361
- _save_masks(vid, data)
362
- return {"ok": True}
363
- raise HTTPException(404, "Masque introuvable")
364
- @app.post("/mask/delete", tags=["mask"])
365
- async def delete_mask(payload: Dict[str, Any] = Body(...)):
366
- vid = payload.get("vid")
367
- mid = payload.get("id")
368
- if not vid or not mid:
369
- raise HTTPException(400, "vid et id requis")
370
- data = _load_masks(vid)
371
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
372
- _save_masks(vid, data)
373
- return {"ok": True}
374
- # --- API autres (inchangé) ---
375
  @app.get("/", tags=["meta"])
376
  def root():
377
- return {"ok": True, "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/warmup", "/warmup_progress", "/mask/ai", "/inpaint", "/estimate", "/progress_ia"]}
378
- @app.get("/health", tags=["meta"])
 
 
 
 
379
  def health():
380
  return {"status": "ok"}
381
- @app.get("/_env", tags=["meta"])
 
382
  def env_info():
383
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
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
- @app.get("/meta/{vid}", tags=["io"])
 
389
  def video_meta(vid: str):
390
  v = DATA_DIR / vid
391
  if not v.exists():
@@ -394,7 +368,8 @@ def video_meta(vid: str):
394
  if not m:
395
  raise HTTPException(500, "Métadonnées indisponibles")
396
  return m
397
- @app.post("/upload", tags=["io"])
 
398
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
399
  ext = (Path(file.filename).suffix or ".mp4").lower()
400
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
@@ -414,10 +389,12 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
414
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
415
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
416
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
417
- @app.get("/progress/{vid_stem}", tags=["io"])
 
418
  def progress(vid_stem: str):
419
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
420
- @app.delete("/delete/{vid}", tags=["io"])
 
421
  def delete_video(vid: str):
422
  v = DATA_DIR / vid
423
  if not v.exists():
@@ -429,7 +406,8 @@ def delete_video(vid: str):
429
  v.unlink(missing_ok=True)
430
  print(f"[DELETE] {vid}", file=sys.stdout)
431
  return {"deleted": vid}
432
- @app.get("/frame_idx", tags=["io"])
 
433
  def frame_idx(vid: str, idx: int):
434
  v = DATA_DIR / vid
435
  if not v.exists():
@@ -444,7 +422,8 @@ def frame_idx(vid: str, idx: int):
444
  except Exception as e:
445
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
446
  raise HTTPException(500, "Frame error")
447
- @app.get("/poster/{vid}", tags=["io"])
 
448
  def poster(vid: str):
449
  v = DATA_DIR / vid
450
  if not v.exists():
@@ -453,7 +432,8 @@ def poster(vid: str):
453
  if p.exists():
454
  return FileResponse(str(p), media_type="image/jpeg")
455
  raise HTTPException(404, "Poster introuvable")
456
- @app.get("/window/{vid}", tags=["io"])
 
457
  def window(vid: str, center: int = 0, count: int = 21):
458
  v = DATA_DIR / vid
459
  if not v.exists():
@@ -480,81 +460,141 @@ def window(vid: str, center: int = 0, count: int = 21):
480
  items.append({"i": i, "idx": idx, "url": url})
481
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
482
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
483
- # --- UI (ajouts prefix pour pointer, bouton estimer) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  HTML_TEMPLATE = r"""
485
  <!doctype html>
486
  <html lang="fr"><meta charset="utf-8">
487
  <title>Video Editor</title>
488
- <style>
489
- :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;}
490
- * {box-sizing:border-box}
491
- body {font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
492
- h1 {margin:0 0 8px 0}
493
- .topbar {display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
494
- .card {border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
495
- .muted {color:var(--muted);font-size:13px}
496
- .layout {display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
497
- .viewer {max-width:1024px;margin:0 auto; position:relative}
498
- .player-wrap {position:relative; padding-bottom: var(--controlsH);}
499
- video {display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
500
- #editCanvas {position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
501
- .timeline-container {margin-top:10px}
502
- .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%}
503
- .thumb {flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
504
- .thumb:hover {transform:scale(1.05)}
505
- .thumb img {height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
506
- .thumb img.sel {border-color:var(--active-border)}
507
- .thumb img.sel-strong {outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
508
- .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}
509
- .thumb-label {font-size:11px;color:var(--muted);margin-top:2px;display:block}
510
- .timeline.filter-masked .thumb:not(.hasmask) {display:none}
511
- .tools .row {display:flex;gap:8px;flex-wrap:wrap;align-items:center}
512
- .btn {padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
513
- .btn:hover {background:var(--active-bg);border-color:var(--active-border)}
514
- .btn.active,.btn.toggled {background:var(--active-bg);border-color:var(--active-border)}
515
- .swatch {width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
516
- .swatch.sel {box-shadow:0 0 0 2px var(--active-border)}
517
- ul.clean {list-style:none;padding-left:0;margin:6px 0}
518
- ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
519
- .rename-btn {font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
520
- .rename-btn:hover {color:#2563eb}
521
- .delete-btn {color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
522
- .delete-btn:hover {color:#b91c1c}
523
- #loading-indicator {display:none;margin-top:6px;color:#f59e0b}
524
- .portion-row {display:flex;gap:6px;align-items:center;margin-top:8px}
525
- .portion-input {width:70px}
526
- #tl-progress-bar {background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
527
- #tl-progress-fill {background:#2563eb;height:100%;width:0;border-radius:4px}
528
- #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}
529
- #popup-progress-bar {background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
530
- #popup-progress-fill {background:#2563eb;height:100%;width:0;border-radius:4px}
531
- #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
532
- .playhead {position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
533
- #portionBand {position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
534
- #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}
535
- #hud {position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
536
- #toast {position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
537
- .toast-item {background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
538
- #tutorial {background:#fef9c3;padding:10px;border-radius:8px;margin-top:12px;display:block}
539
- #tutorial.hidden {display:none}
540
- #warmupBtn {margin-top:8px}
541
- #iaPreviewBtn {margin-top:8px}
542
- #iaProgress {margin-top:8px; background:#f3f4f6;border-radius:4px;height:8px}
543
- #iaProgressFill {background:#2563eb;height:100%;width:0;border-radius:4px}
544
- #iaLogs {font-size:12px;color:#6b7280;margin-top:4px}
545
- #multiMaskList {max-height:150px;overflow:auto}
546
- #estimateBtn {margin-top:8px}
547
  </style>
548
  <h1>🎬 Video Editor</h1>
549
  <div class="topbar card">
550
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
551
- <strong>Charger une vidéo :</strong>
552
- <input type="file" name="file" accept="video/*" required>
553
- <button class="btn" type="submit">Uploader</button>
554
  </form>
555
  <span class="muted" id="msg">__MSG__</span>
556
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
557
  </div>
 
 
 
 
 
 
 
 
558
  <div class="layout">
559
  <div>
560
  <div class="viewer card" id="viewerCard">
@@ -570,6 +610,7 @@ ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
570
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
571
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
572
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
 
573
  <span id="posInfo" style="margin-left:10px"></span>
574
  <span id="status" style="margin-left:10px;color:#2563eb"></span>
575
  </div>
@@ -597,8 +638,6 @@ ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
597
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
598
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
599
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
600
- <button id="undoBtn" class="btn" style="display:none">↩️ Undo</button>
601
- <button id="redoBtn" class="btn" style="display:none">↪️ Redo</button>
602
  </div>
603
  <div style="margin-top:10px">
604
  <div class="muted">Couleur</div>
@@ -614,19 +653,13 @@ ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
614
  <details open>
615
  <summary><strong>Masques</strong></summary>
616
  <div id="maskList" class="muted">—</div>
617
- <div id="multiMaskList" class="muted">—</div>
618
  <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
619
- <button class="btn" id="iaPreviewBtn" title="Preview IA (en développement)">🔍 Preview IA</button>
620
- <button class="btn" id="estimateBtn" title="Estimer temps/VRAM">⏱️ Estimer</button>
621
  </details>
622
- <div id="iaProgress"><div id="iaProgressFill"></div></div>
623
- <div id="iaLogs"></div>
624
  <div style="margin-top:6px">
625
  <strong>Vidéos disponibles</strong>
626
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
627
  </div>
628
  </div>
629
- <button class="btn" id="warmupBtn">Warm-up Modèles</button>
630
  </div>
631
  </div>
632
  <div id="popup">
@@ -635,18 +668,11 @@ ul.clean li {margin:2px 0;display:flex;align-items:center;gap:6px}
635
  <div id="popup-logs"></div>
636
  </div>
637
  <div id="toast"></div>
638
- <div id="tutorial">
639
- <h4>Tutoriel</h4>
640
- <p>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</p>
641
- <button onclick="this.parentElement.classList.add('hidden')">Masquer</button>
642
- </div>
643
  <script>
644
  const serverVid = "__VID__";
645
  const serverMsg = "__MSG__";
646
  document.getElementById('msg').textContent = serverMsg;
647
- // Ajout prefix pour pointer/backend
648
- let prefix = '';
649
- // Elements (inchangé)
650
  const statusEl = document.getElementById('status');
651
  const player = document.getElementById('player');
652
  const srcEl = document.getElementById('vidsrc');
@@ -680,16 +706,13 @@ const hud = document.getElementById('hud');
680
  const toastWrap = document.getElementById('toast');
681
  const gotoInput = document.getElementById('gotoInput');
682
  const gotoBtn = document.getElementById('gotoBtn');
683
- const tutorial = document.getElementById('tutorial');
684
- const warmupBtn = document.getElementById('warmupBtn');
685
- const iaPreviewBtn = document.getElementById('iaPreviewBtn');
686
- const iaProgressFill = document.getElementById('iaProgressFill');
687
- const iaLogs = document.getElementById('iaLogs');
688
- const undoBtn = document.getElementById('undoBtn');
689
- const redoBtn = document.getElementById('redoBtn');
690
- const multiMaskList = document.getElementById('multiMaskList');
691
- const estimateBtn = document.getElementById('estimateBtn');
692
- // State (inchangé)
693
  let vidName = serverVid || '';
694
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
695
  let vidStem = '';
@@ -697,17 +720,14 @@ let bustToken = Date.now();
697
  let fps = 30, frames = 0;
698
  let currentIdx = 0;
699
  let mode = 'view';
700
- let rects = [];
701
- let dragging=false, sx=0, sy=0, currentRect = null;
702
  let color = '#10b981';
703
- let rectMap = new Map();
704
- let history = [];
705
- let historyIdx = -1;
706
  let masks = [];
707
  let maskedSet = new Set();
708
  let timelineUrls = [];
709
  let portionStart = null;
710
- let portionEnd = null;
711
  let loopInterval = null;
712
  let chunkSize = 50;
713
  let timelineStart = 0, timelineEnd = 0;
@@ -720,7 +740,8 @@ let lastCenterMs = 0;
720
  const CENTER_THROTTLE_MS = 150;
721
  const PENDING_KEY = 've_pending_masks_v1';
722
  let maskedOnlyMode = false;
723
- // Utils (inchangé)
 
724
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
725
  function ensureOverlays(){
726
  if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
@@ -740,22 +761,28 @@ function updateSelectedThumb(){
740
  img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
741
  }
742
  function rawCenterThumb(el){
743
- tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
 
 
 
 
 
744
  }
745
  async function ensureThumbVisibleCentered(idx){
746
- for(let k=0; k<40; k++){
747
  const el = findThumbEl(idx);
748
  if(el){
749
  const img = el.querySelector('img');
750
  if(!img.complete || img.naturalWidth === 0){
751
- await new Promise(r=>setTimeout(r, 25));
752
  }else{
753
  rawCenterThumb(el);
 
754
  updatePlayhead();
755
  return true;
756
  }
757
  }else{
758
- await new Promise(r=>setTimeout(r, 25));
759
  }
760
  }
761
  return false;
@@ -803,15 +830,6 @@ async function flushPending(){
803
  savePendingList(kept);
804
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
805
  }
806
- const AUTOSAVE_KEY = 've_autosave_rects_v1';
807
- function saveAutoRects(){ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({vid:vidName, idx:currentIdx, rects:rects})); }
808
- function loadAutoRects(){
809
- try{
810
- const d = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) || '{}');
811
- if(d.vid === vidName && d.idx === currentIdx){ rects = d.rects || []; draw(); }
812
- }catch{}
813
- }
814
- // Layout (inchangé)
815
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
816
  function fitCanvas(){
817
  const r=player.getBoundingClientRect();
@@ -833,79 +851,57 @@ function setMode(m){
833
  modeLabel.textContent='Édition';
834
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
835
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
836
- undoBtn.style.display='inline-block'; redoBtn.style.display='inline-block';
837
  canvas.style.pointerEvents='auto';
838
- rects = rectMap.get(currentIdx) || [];
839
- history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); loadAutoRects(); updateUndoRedo();
840
  }else{
841
  player.controls = true;
842
  playerWrap.classList.remove('edit-mode');
843
  modeLabel.textContent='Lecture';
844
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
845
  btnSave.style.display='none'; btnClear.style.display='none';
846
- undoBtn.style.display='none'; redoBtn.style.display='none';
847
  canvas.style.pointerEvents='none';
848
- rects=[]; draw();
849
  }
850
  }
851
  function draw(){
852
  ctx.clearRect(0,0,canvas.width,canvas.height);
853
- rects.forEach(r => {
854
- const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2);
855
- const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1);
856
- ctx.strokeStyle=r.color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
857
- ctx.fillStyle=r.color+'28'; ctx.fillRect(x,y,w,h);
858
- });
859
  }
860
  canvas.addEventListener('mousedown',(e)=>{
861
  if(mode!=='edit' || !vidName) return;
862
  dragging=true; const r=canvas.getBoundingClientRect();
863
  sx=e.clientX-r.left; sy=e.clientY-r.top;
864
- currentRect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; rects.push(currentRect); draw(); saveAutoRects();
865
  });
866
  canvas.addEventListener('mousemove',(e)=>{
867
  if(!dragging) return;
868
  const r=canvas.getBoundingClientRect();
869
- currentRect.x2=e.clientX-r.left; currentRect.y2=e.clientY-r.top; draw();
870
  });
871
- ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{
872
- dragging=false;
873
- if(currentRect){ pushHistory(); currentRect = null; }
874
- }));
875
- function pushHistory(){
876
- history = history.slice(0, historyIdx + 1);
877
- history.push(JSON.stringify(rects));
878
- historyIdx++;
879
- updateUndoRedo();
880
- saveAutoRects();
881
- }
882
- function updateUndoRedo(){
883
- undoBtn.disabled = historyIdx <= 0;
884
- redoBtn.disabled = historyIdx >= history.length - 1;
885
- }
886
- undoBtn.onclick = () => {
887
- if(historyIdx > 0){ historyIdx--; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
888
- };
889
- redoBtn.onclick = () => {
890
- if(historyIdx < history.length - 1){ historyIdx++; rects = JSON.parse(history[historyIdx]); draw(); updateUndoRedo(); saveAutoRects(); }
891
- };
892
- btnClear.onclick=()=>{ if(rects.length) rects.pop(); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
893
  btnEdit.onclick =()=> setMode('edit');
894
  btnBack.onclick =()=> setMode('view');
 
895
  palette.querySelectorAll('.swatch').forEach(el=>{
896
  if(el.dataset.c===color) el.classList.add('sel');
897
  el.onclick=()=>{
898
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
899
  el.classList.add('sel'); color=el.dataset.c;
900
- if(currentRect){ currentRect.color=color; draw(); }
901
  };
902
  });
903
- // Timeline (préfixes ajoutés)
904
  async function loadTimelineUrls(){
905
  timelineUrls = [];
906
  const stem = vidStem, b = bustToken;
907
  for(let idx=0; idx<frames; idx++){
908
- timelineUrls[idx] = prefix + `thumbs/f_${stem}_${idx}.jpg?b=${b}`;
909
  }
910
  tlProgressFill.style.width='0%';
911
  }
@@ -920,10 +916,23 @@ async function renderTimeline(centerIdx){
920
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
921
  return;
922
  }
923
- if(portionStart!=null && portionEnd!=null){
924
- const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
925
- for(let i=s;i<e;i++){ addThumb(i,'append'); }
926
- setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  return;
928
  }
929
  await loadWindow(centerIdx ?? currentIdx);
@@ -952,11 +961,11 @@ function addThumb(idx, place='append'){
952
  const img=new Image(); img.title='frame '+(idx+1);
953
  img.src=timelineUrls[idx];
954
  img.onerror = () => {
955
- const fallback = prefix + `frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
956
  img.onerror = null;
957
  img.src = fallback;
958
  img.onload = () => {
959
- const nu = prefix + `thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
960
  timelineUrls[idx] = nu;
961
  img.src = nu;
962
  img.onload = null;
@@ -965,7 +974,7 @@ function addThumb(idx, place='append'){
965
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
966
  img.onclick=async ()=>{
967
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
968
- if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
969
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
970
  };
971
  wrap.appendChild(img);
@@ -976,6 +985,7 @@ function addThumb(idx, place='append'){
976
  else{ tlBox.appendChild(wrap); }
977
  thumbEls.set(idx, wrap);
978
  }
 
979
  tlBox.addEventListener('scroll', ()=>{
980
  if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
981
  updatePlayhead(); updatePortionOverlays();
@@ -995,13 +1005,15 @@ tlBox.addEventListener('scroll', ()=>{
995
  }
996
  updatePlayhead(); updatePortionOverlays();
997
  });
 
998
  isolerBoucle.onclick = async ()=>{
999
- const start = parseInt(goFrame.value || '1',10) - 1;
1000
- const end = parseInt(endPortion.value || '',10);
1001
- if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
1002
- if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
1003
- portionStart = start; portionEnd = end;
1004
- viewRangeStart = start; viewRangeEnd = end;
 
1005
  player.pause(); isPaused = true;
1006
  currentIdx = start; player.currentTime = idxToSec(start);
1007
  await renderTimeline(currentIdx);
@@ -1023,6 +1035,7 @@ resetFull.onclick = async ()=>{
1023
  resetFull.style.display='none';
1024
  clearInterval(loopInterval); updatePortionOverlays();
1025
  };
 
1026
  function attachHandleDrag(handle, which){
1027
  let draggingH=false;
1028
  function onMove(e){
@@ -1037,7 +1050,8 @@ function attachHandleDrag(handle, which){
1037
  window.addEventListener('mousemove', onMove);
1038
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1039
  }
1040
- ensureOverlays(); attachHandleDrag(inHandle(), 'in'); attachHandleDrag(outHandle(), 'out');
 
1041
  async function showProgress(vidStem){
1042
  popup.style.display = 'block';
1043
  const interval = setInterval(async () => {
@@ -1045,7 +1059,7 @@ async function showProgress(vidStem){
1045
  const d = await r.json();
1046
  tlProgressFill.style.width = d.percent + '%';
1047
  popupProgressFill.style.width = d.percent + '%';
1048
- popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
1049
  if(d.done){
1050
  clearInterval(interval);
1051
  popup.style.display = 'none';
@@ -1053,17 +1067,18 @@ async function showProgress(vidStem){
1053
  }
1054
  }, 800);
1055
  }
 
1056
  async function loadVideoAndMeta() {
1057
  if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1058
  vidStem = fileStem(vidName); bustToken = Date.now();
1059
  const bust = Date.now();
1060
- srcEl.src = prefix + 'data/' + encodeURIComponent(vidName) + '?t=' + bust;
1061
- player.setAttribute('poster', prefix + 'poster/' + encodeURIComponent(vidName) + '?t=' + bust);
1062
  player.load();
1063
  fitCanvas();
1064
  statusEl.textContent = 'Chargement vidéo…';
1065
  try{
1066
- const r=await fetch(prefix + 'meta/' + encodeURIComponent(vidName));
1067
  if(r.ok){
1068
  const m=await r.json();
1069
  fps=m.fps||30; frames=m.frames||0;
@@ -1084,9 +1099,9 @@ async function loadVideoAndMeta() {
1084
  player.addEventListener('loadedmetadata', async ()=>{
1085
  fitCanvas();
1086
  if(!frames || frames<=0){
1087
- try{ const r=await fetch(prefix + 'meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
1088
  }
1089
- currentIdx=0; goFrame.value=1; rectMap.clear(); rects=[]; draw();
1090
  });
1091
  window.addEventListener('resize', ()=>{ fitCanvas(); });
1092
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
@@ -1094,22 +1109,24 @@ player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); }
1094
  player.addEventListener('timeupdate', ()=>{
1095
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1096
  currentIdx=timeToIdx(player.currentTime);
1097
- if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1098
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1099
  if(followMode && !isPaused){
1100
  const now = Date.now();
1101
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1102
  }
1103
  });
 
1104
  goFrame.addEventListener('change', async ()=>{
1105
  if(!vidName) return;
1106
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1107
  player.pause(); isPaused = true;
1108
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1109
- if(mode==='edit'){ rects = rectMap.get(currentIdx)||[]; history = [JSON.stringify(rects)]; historyIdx = 0; draw(); loadMultiMasks(); }
1110
  await renderTimeline(currentIdx);
1111
  await ensureThumbVisibleCentered(currentIdx);
1112
  });
 
1113
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1114
  btnFilterMasked.onclick = async ()=>{
1115
  maskedOnlyMode = !maskedOnlyMode;
@@ -1130,6 +1147,7 @@ async function gotoFrameNum(){
1130
  }
1131
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1132
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
 
1133
  const uploadZone = document.getElementById('uploadForm');
1134
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1135
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
@@ -1141,7 +1159,9 @@ uploadZone.addEventListener('drop', (e) => {
1141
  fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1142
  }
1143
  });
1144
- document.getElementById('exportBtn').onclick = () => { alert('Fonctionnalité export IA en développement !'); };
 
 
1145
  async function loadFiles(){
1146
  const r=await fetch('/files'); const d=await r.json();
1147
  if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
@@ -1161,19 +1181,17 @@ async function loadFiles(){
1161
  async function loadMasks(){
1162
  loadingInd.style.display='block';
1163
  const box=document.getElementById('maskList');
1164
- const r=await fetch(prefix + 'mask/'+encodeURIComponent(vidName));
1165
  const d=await r.json();
1166
  masks=d.masks||[];
1167
- maskedSet = new Set();
1168
  rectMap.clear();
 
1169
  masks.forEach(m=>{
1170
- const idx = m.frame_idx;
1171
- maskedSet.add(idx);
1172
- if(!rectMap.has(idx)) rectMap.set(idx, []);
1173
  if(m.shape==='rect'){
1174
  const [x1,y1,x2,y2] = m.points;
1175
  const normW = canvas.clientWidth, normH = canvas.clientHeight;
1176
- rectMap.get(idx).push({x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color, id:m.id, note:m.note});
1177
  }
1178
  });
1179
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
@@ -1208,70 +1226,71 @@ async function loadMasks(){
1208
  });
1209
  box.appendChild(ul);
1210
  loadingInd.style.display='none';
1211
- if(mode === 'edit'){ loadMultiMasks(); loadAutoRects(); }
1212
  }
1213
- function loadMultiMasks(){
1214
- multiMaskList.innerHTML = '';
1215
- const curRects = rectMap.get(currentIdx) || rects; // use saved or current
1216
- if(!curRects.length){ multiMaskList.textContent = 'Aucun masque pour cette frame'; return; }
1217
- const ul = document.createElement('ul'); ul.className='clean';
1218
- curRects.forEach((r, i) => {
1219
- const li = document.createElement('li');
1220
- li.innerHTML = `<span style="background:${r.color};width:10px;height:10px;border-radius:50%"></span> Masque ${i+1}`;
1221
- const del = document.createElement('span'); del.className='delete-btn'; del.textContent='❌';
1222
- del.onclick = () => { rects.splice(i,1); pushHistory(); draw(); loadMultiMasks(); saveAutoRects(); };
1223
- li.appendChild(del);
1224
- ul.appendChild(li);
1225
- });
1226
- multiMaskList.appendChild(ul);
1227
- }
1228
- warmupBtn.onclick = async () => {
1229
- const r = await fetch('/warmup', {method:'POST'});
1230
- if(r.ok){
1231
- showToast('Warm-up lancé');
1232
- const interval = setInterval(async () => {
1233
- const pr = await fetch('/warmup_progress');
1234
- const d = await pr.json();
1235
- iaProgressFill.style.width = d.percent + '%';
1236
- iaLogs.innerHTML = d.logs.join('<br>');
1237
- if(d.done){ clearInterval(interval); }
1238
- }, 1000);
1239
- } else {
1240
- showToast('Warm-up déjà en cours ou erreur');
1241
  }
1242
  };
1243
- iaPreviewBtn.onclick = () => { alert('En développement, switch GPU'); };
1244
- const iaPollInterval = setInterval(async () => {
1245
- if(vidName){
1246
- const r = await fetch('/progress_ia?vid=' + encodeURIComponent(vidName));
1247
- const d = await r.json();
1248
- iaProgressFill.style.width = d.percent + '%';
1249
- iaLogs.textContent = d.log;
1250
- }
1251
- }, 2000);
1252
- estimateBtn.onclick = async () => {
1253
- const r = await fetch('/estimate?vid=' + encodeURIComponent(vidName) + '&masks_count=' + masks.length);
1254
- const d = await r.json();
1255
- alert(`Temps: ${d.time_min}, VRAM: ${d.vram_gb}`);
 
 
 
 
 
1256
  };
 
1257
  async function boot(){
1258
- const envR = await fetch('/_env');
1259
- const envD = await envR.json();
1260
- prefix = envD.pointer_set ? '/p/' : '';
1261
- await flushPending();
1262
  await loadFiles();
1263
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1264
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1265
- if(!localStorage.getItem('tutorial_seen')){ tutorial.classList.remove('hidden'); localStorage.setItem('tutorial_seen', '1'); }
1266
  }
1267
  boot();
 
1268
  const style = document.createElement('style');
1269
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1270
  document.head.appendChild(style);
1271
  </script>
1272
  </html>
1273
  """
1274
- @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
 
1275
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1276
  vid = v or ""
1277
  try:
@@ -1279,4 +1298,4 @@ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1279
  except Exception:
1280
  pass
1281
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1282
- 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
+
8
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
9
  from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
10
  from fastapi.staticfiles import StaticFiles
 
16
  import shutil as _shutil
17
  import os
18
  import httpx
19
+
20
+ print("[BOOT] Video Editor API starting…")
21
+
22
+ # --- POINTEUR DE BACKEND -----------------------------------------------------
23
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
24
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
25
  _backend_url_cache = {"url": None, "ts": 0.0}
26
+
27
  def get_backend_base() -> str:
28
  try:
29
  if POINTER_URL:
30
  now = time.time()
31
+ need_refresh = (not _backend_url_cache["url"]) or (now - _backend_url_cache["ts"] > 30)
32
  if need_refresh:
33
  r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
34
  url = (r.text or "").strip()
 
41
  return FALLBACK_BASE
42
  except Exception:
43
  return FALLBACK_BASE
44
+
45
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
46
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
47
+
48
+ app = FastAPI(title="Video Editor API", version="0.8.2")
49
+
50
+ # --- DATA DIRS ----------------------------------------------------------------
51
  DATA_DIR = Path("/app/data")
52
  THUMB_DIR = DATA_DIR / "_thumbs"
53
  MASK_DIR = DATA_DIR / "_masks"
54
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
55
  p.mkdir(parents=True, exist_ok=True)
56
+
57
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
58
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
59
+
60
+ # --- PROXY VERS LE BACKEND ----------------------------------------------------
61
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
62
  async def proxy_all(full_path: str, request: Request):
63
  base = get_backend_base().rstrip("/")
 
75
  "te","trailers","upgrade"}
76
  out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
77
  return Response(content=r.content, status_code=r.status_code, headers=out_headers)
78
+
79
+ # --- THUMBS PROGRESS (vid_stem -> state) -------------------------------------
80
+ progress_data: Dict[str, Dict[str, Any]] = {}
81
+
82
+ # --- HELPERS ------------------------------------------------------------------
83
+
84
  def _is_video(p: Path) -> bool:
85
  return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
86
+
87
  def _safe_name(name: str) -> str:
88
  return Path(name).name.replace(" ", "_")
89
+
90
  def _has_ffmpeg() -> bool:
91
  return _shutil.which("ffmpeg") is not None
92
+
93
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
94
  return f"scale=min(iw\\,{max_w}):-2"
95
+
96
  def _meta(video: Path):
97
  cap = cv2.VideoCapture(str(video))
98
  if not cap.isOpened():
 
105
  cap.release()
106
  print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
107
  return {"frames": frames, "fps": fps, "w": w, "h": h}
108
+
109
  def _frame_jpg(video: Path, idx: int) -> Path:
110
  out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
111
  if out.exists():
 
151
  img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
152
  cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
153
  return out
154
+
155
  def _poster(video: Path) -> Path:
156
  out = THUMB_DIR / f"poster_{video.stem}.jpg"
157
  if out.exists():
 
166
  except Exception as e:
167
  print(f"[POSTER] Failed: {e}", file=sys.stdout)
168
  return out
169
+
170
  def _mask_file(vid: str) -> Path:
171
  return MASK_DIR / f"{Path(vid).name}.json"
172
+
173
  def _load_masks(vid: str) -> Dict[str, Any]:
174
  f = _mask_file(vid)
175
  if f.exists():
 
178
  except Exception as e:
179
  print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
180
  return {"video": vid, "masks": []}
181
+
182
  def _save_masks(vid: str, data: Dict[str, Any]):
183
  _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
184
+
185
+ # --- THUMBS GENERATION BG -----------------------------------------------------
186
+
187
  def _gen_thumbs_background(video: Path, vid_stem: str):
188
  progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
189
  try:
 
260
  except Exception as e:
261
  progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
262
  progress_data[vid_stem]['done'] = True
263
+
264
+ # --- WARM-UP (Hub) ------------------------------------------------------------
265
+ from huggingface_hub import snapshot_download
266
+ try:
267
+ from huggingface_hub.utils import HfHubHTTPError # 0.13+
268
+ except Exception:
269
+ class HfHubHTTPError(Exception):
270
+ pass
271
+
272
+ warmup_state: Dict[str, Any] = {
273
+ "state": "idle", # idle|running|done|error
274
+ "running": False,
275
+ "percent": 0,
276
+ "current": "",
277
+ "idx": 0,
278
+ "total": 0,
279
+ "log": [],
280
+ "started_at": None,
281
+ "finished_at": None,
282
+ "last_error": "",
283
+ }
284
+
285
+ WARMUP_MODELS: List[str] = [
286
+ "facebook/sam2-hiera-large",
287
+ "lixiaowen/diffuEraser",
288
+ "runwayml/stable-diffusion-v1-5",
289
+ "stabilityai/sd-vae-ft-mse",
290
+ "ByteDance/Sa2VA-4B",
291
+ "wangfuyun/PCM_Weights",
292
  ]
293
+
294
+ def _append_warmup_log(msg: str):
295
+ warmup_state["log"].append(msg)
296
+ if len(warmup_state["log"]) > 200:
297
+ warmup_state["log"] = warmup_state["log"][-200:]
298
+
299
+ def _do_warmup():
300
+ token = os.getenv("HF_TOKEN", None)
301
+ warmup_state.update({
302
+ "state": "running", "running": True, "percent": 0,
303
+ "idx": 0, "total": len(WARMUP_MODELS), "current": "",
304
+ "started_at": time.time(), "finished_at": None, "last_error": "",
305
+ "log": []
306
+ })
307
+ try:
308
+ total = len(WARMUP_MODELS)
309
+ for i, repo in enumerate(WARMUP_MODELS):
310
+ warmup_state["current"] = repo
311
+ warmup_state["idx"] = i
312
+ base_pct = int((i / max(1, total)) * 100)
313
+ warmup_state["percent"] = min(99, base_pct)
314
+ _append_warmup_log(f"➡️ Téléchargement: {repo}")
315
+ try:
316
+ snapshot_download(repo_id=repo, token=token)
317
+ _append_warmup_log(f"✅ OK: {repo}")
318
+ except HfHubHTTPError as he:
319
+ _append_warmup_log(f"⚠️ HubHTTPError {repo}: {he}")
320
+ except Exception as e:
321
+ _append_warmup_log(f"⚠️ Erreur {repo}: {e}")
322
+ # après chaque repo
323
+ warmup_state["percent"] = int(((i+1) / max(1, total)) * 100)
324
+ warmup_state.update({"state":"done","running":False,"finished_at":time.time(),"current":"","idx":total})
325
+ except Exception as e:
326
+ warmup_state.update({"state":"error","running":False,"last_error":str(e),"finished_at":time.time()})
327
+ _append_warmup_log(f"❌ Warm-up erreur: {e}")
328
+
329
+ @app.post("/warmup/start", tags=["warmup"])
330
+ def warmup_start():
331
+ if warmup_state.get("running"):
332
+ return {"ok": False, "detail": "already running", "state": warmup_state}
333
+ t = threading.Thread(target=_do_warmup, daemon=True)
334
+ t.start()
335
+ return {"ok": True, "state": warmup_state}
336
+
337
+ @app.get("/warmup/status", tags=["warmup"])
338
+ def warmup_status():
339
+ return warmup_state
340
+
341
+ # --- API ROUTES ---------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  @app.get("/", tags=["meta"])
343
  def root():
344
+ return {
345
+ "ok": True,
346
+ "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"]
347
+ }
348
+
349
+ @app.get("/health", tags=["meta"])
350
  def health():
351
  return {"status": "ok"}
352
+
353
+ @app.get("/_env", tags=["meta"])
354
  def env_info():
355
  return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
356
+
357
+ @app.get("/files", tags=["io"])
358
  def files():
359
  items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
360
  return {"count": len(items), "items": items}
361
+
362
+ @app.get("/meta/{vid}", tags=["io"])
363
  def video_meta(vid: str):
364
  v = DATA_DIR / vid
365
  if not v.exists():
 
368
  if not m:
369
  raise HTTPException(500, "Métadonnées indisponibles")
370
  return m
371
+
372
+ @app.post("/upload", tags=["io"])
373
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
374
  ext = (Path(file.filename).suffix or ".mp4").lower()
375
  if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
 
389
  msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
390
  return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
391
  return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
392
+
393
+ @app.get("/progress/{vid_stem}", tags=["io"])
394
  def progress(vid_stem: str):
395
  return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
396
+
397
+ @app.delete("/delete/{vid}", tags=["io"])
398
  def delete_video(vid: str):
399
  v = DATA_DIR / vid
400
  if not v.exists():
 
406
  v.unlink(missing_ok=True)
407
  print(f"[DELETE] {vid}", file=sys.stdout)
408
  return {"deleted": vid}
409
+
410
+ @app.get("/frame_idx", tags=["io"])
411
  def frame_idx(vid: str, idx: int):
412
  v = DATA_DIR / vid
413
  if not v.exists():
 
422
  except Exception as e:
423
  print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
424
  raise HTTPException(500, "Frame error")
425
+
426
+ @app.get("/poster/{vid}", tags=["io"])
427
  def poster(vid: str):
428
  v = DATA_DIR / vid
429
  if not v.exists():
 
432
  if p.exists():
433
  return FileResponse(str(p), media_type="image/jpeg")
434
  raise HTTPException(404, "Poster introuvable")
435
+
436
+ @app.get("/window/{vid}", tags=["io"])
437
  def window(vid: str, center: int = 0, count: int = 21):
438
  v = DATA_DIR / vid
439
  if not v.exists():
 
460
  items.append({"i": i, "idx": idx, "url": url})
461
  print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
462
  return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
463
+
464
+ # ----- Masques ---------------------------------------------------------------
465
+ @app.post("/mask", tags=["mask"])
466
+ async def save_mask(payload: Dict[str, Any] = Body(...)):
467
+ vid = payload.get("vid")
468
+ if not vid:
469
+ raise HTTPException(400, "vid manquant")
470
+ pts = payload.get("points") or []
471
+ if len(pts) != 4:
472
+ raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
473
+ data = _load_masks(vid)
474
+ m = {
475
+ "id": uuid.uuid4().hex[:10],
476
+ "time_s": float(payload.get("time_s") or 0.0),
477
+ "frame_idx": int(payload.get("frame_idx") or 0),
478
+ "shape": "rect",
479
+ "points": [float(x) for x in pts],
480
+ "color": payload.get("color") or "#10b981",
481
+ "note": payload.get("note") or ""
482
+ }
483
+ data.setdefault("masks", []).append(m)
484
+ _save_masks(vid, data)
485
+ print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
486
+ return {"saved": True, "mask": m}
487
+
488
+ @app.get("/mask/{vid}", tags=["mask"])
489
+ def list_masks(vid: str):
490
+ return _load_masks(vid)
491
+
492
+ @app.post("/mask/rename", tags=["mask"])
493
+ async def rename_mask(payload: Dict[str, Any] = Body(...)):
494
+ vid = payload.get("vid")
495
+ mid = payload.get("id")
496
+ new_note = (payload.get("note") or "").strip()
497
+ if not vid or not mid:
498
+ raise HTTPException(400, "vid et id requis")
499
+ data = _load_masks(vid)
500
+ for m in data.get("masks", []):
501
+ if m.get("id") == mid:
502
+ m["note"] = new_note
503
+ _save_masks(vid, data)
504
+ return {"ok": True}
505
+ raise HTTPException(404, "Masque introuvable")
506
+
507
+ @app.post("/mask/delete", tags=["mask"])
508
+ async def delete_mask(payload: Dict[str, Any] = Body(...)):
509
+ vid = payload.get("vid")
510
+ mid = payload.get("id")
511
+ if not vid or not mid:
512
+ raise HTTPException(400, "vid et id requis")
513
+ data = _load_masks(vid)
514
+ data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
515
+ _save_masks(vid, data)
516
+ return {"ok": True}
517
+
518
+ # --- UI ----------------------------------------------------------------------
519
  HTML_TEMPLATE = r"""
520
  <!doctype html>
521
  <html lang="fr"><meta charset="utf-8">
522
  <title>Video Editor</title>
523
+ <style>
524
+ :root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb}
525
+ *{box-sizing:border-box}
526
+ body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111}
527
+ h1{margin:0 0 8px 0}
528
+ .topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
529
+ .card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff}
530
+ .muted{color:var(--muted);font-size:13px}
531
+ .layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
532
+ .viewer{max-width:1024px;margin:0 auto; position:relative}
533
+ .player-wrap{position:relative; padding-bottom: var(--controlsH);}
534
+ video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd}
535
+ #editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none}
536
+ .timeline-container{margin-top:10px}
537
+ .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%}
538
+ .thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center}
539
+ .thumb:hover{transform:scale(1.05)}
540
+ .thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover}
541
+ .thumb img.sel{border-color:var(--active-border)}
542
+ .thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)}
543
+ .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}
544
+ .thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block}
545
+ .timeline.filter-masked .thumb:not(.hasmask){display:none}
546
+ .tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
547
+ .btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s}
548
+ .btn:hover{background:var(--active-bg);border-color:var(--active-border)}
549
+ .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
550
+ .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
551
+ .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
552
+ ul.clean{list-style:none;padding-left:0;margin:6px 0}
553
+ ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
554
+ .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s}
555
+ .rename-btn:hover{color:#2563eb}
556
+ .delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s}
557
+ .delete-btn:hover{color:#b91c1c}
558
+ #loading-indicator{display:none;margin-top:6px;color:#f59e0b}
559
+ .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
560
+ .portion-input{width:70px}
561
+ #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
562
+ #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
563
+ #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}
564
+ #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
565
+ #popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
566
+ #popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280}
567
+ .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block}
568
+ #portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none}
569
+ #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}
570
+ #hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px}
571
+ #toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000}
572
+ .toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95}
573
+ /* Warmup UI */
574
+ #warmupBox{display:flex;align-items:center;gap:8px}
575
+ #warmup-progress{flex:1;height:8px;background:#eef2f7;border-radius:4px;overflow:hidden}
576
+ #warmup-progress > div{height:100%;width:0;background:#10b981;border-radius:4px}
577
+ #warmup-status{font-size:12px;color:#334155;min-width:140px}
578
+ .err{color:#b91c1c}
 
 
 
579
  </style>
580
  <h1>🎬 Video Editor</h1>
581
  <div class="topbar card">
582
  <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
583
+ <strong>Charger une vidéo :</strong>
584
+ <input type="file" name="file" accept="video/*" required>
585
+ <button class="btn" type="submit">Uploader</button>
586
  </form>
587
  <span class="muted" id="msg">__MSG__</span>
588
  <span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
589
  </div>
590
+ <div class="card" style="margin-bottom:10px">
591
+ <div id="warmupBox">
592
+ <button id="warmupBtn" class="btn">⚡ Warm‑up modèles (Hub)</button>
593
+ <div id="warmup-status">—</div>
594
+ <div id="warmup-progress"><div id="warmup-fill"></div></div>
595
+ </div>
596
+ <div class="muted" id="warmup-log" style="margin-top:6px;max-height:90px;overflow:auto"></div>
597
+ </div>
598
  <div class="layout">
599
  <div>
600
  <div class="viewer card" id="viewerCard">
 
610
  <label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label>
611
  <button id="isolerBoucle" class="btn">Isoler & Boucle</button>
612
  <button id="resetFull" class="btn" style="display:none">Retour full</button>
613
+ <span class="muted" style="margin-left:10px">Astuce: fin = exclusive ("55" inclut #55)</span>
614
  <span id="posInfo" style="margin-left:10px"></span>
615
  <span id="status" style="margin-left:10px;color:#2563eb"></span>
616
  </div>
 
638
  <button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
639
  <button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
640
  <button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
 
 
641
  </div>
642
  <div style="margin-top:10px">
643
  <div class="muted">Couleur</div>
 
653
  <details open>
654
  <summary><strong>Masques</strong></summary>
655
  <div id="maskList" class="muted">—</div>
 
656
  <button class="btn" style="margin-top:8px" id="exportBtn" title="Exporter la vidéo avec les masques appliqués (bientôt IA)">Exporter vidéo modifiée</button>
 
 
657
  </details>
 
 
658
  <div style="margin-top:6px">
659
  <strong>Vidéos disponibles</strong>
660
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
661
  </div>
662
  </div>
 
663
  </div>
664
  </div>
665
  <div id="popup">
 
668
  <div id="popup-logs"></div>
669
  </div>
670
  <div id="toast"></div>
 
 
 
 
 
671
  <script>
672
  const serverVid = "__VID__";
673
  const serverMsg = "__MSG__";
674
  document.getElementById('msg').textContent = serverMsg;
675
+ // Elements
 
 
676
  const statusEl = document.getElementById('status');
677
  const player = document.getElementById('player');
678
  const srcEl = document.getElementById('vidsrc');
 
706
  const toastWrap = document.getElementById('toast');
707
  const gotoInput = document.getElementById('gotoInput');
708
  const gotoBtn = document.getElementById('gotoBtn');
709
+ // Warmup UI
710
+ const warmBtn = document.getElementById('warmupBtn');
711
+ const warmStat = document.getElementById('warmup-status');
712
+ const warmBar = document.getElementById('warmup-fill');
713
+ const warmLog = document.getElementById('warmup-log');
714
+
715
+ // State
 
 
 
716
  let vidName = serverVid || '';
717
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
718
  let vidStem = '';
 
720
  let fps = 30, frames = 0;
721
  let currentIdx = 0;
722
  let mode = 'view';
723
+ let rect = null, dragging=false, sx=0, sy=0;
 
724
  let color = '#10b981';
725
+ let rectMap = new Map();
 
 
726
  let masks = [];
727
  let maskedSet = new Set();
728
  let timelineUrls = [];
729
  let portionStart = null;
730
+ let portionEnd = null; // exclusive
731
  let loopInterval = null;
732
  let chunkSize = 50;
733
  let timelineStart = 0, timelineEnd = 0;
 
740
  const CENTER_THROTTLE_MS = 150;
741
  const PENDING_KEY = 've_pending_masks_v1';
742
  let maskedOnlyMode = false;
743
+
744
+ // Utils
745
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
746
  function ensureOverlays(){
747
  if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
 
761
  img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
762
  }
763
  function rawCenterThumb(el){
764
+ const boxRect = tlBox.getBoundingClientRect();
765
+ const elRect = el.getBoundingClientRect();
766
+ const current = tlBox.scrollLeft;
767
+ const elMid = (elRect.left - boxRect.left) + current + (elRect.width / 2);
768
+ const target = Math.max(0, elMid - (tlBox.clientWidth / 2));
769
+ tlBox.scrollTo({ left: target, behavior: 'auto' });
770
  }
771
  async function ensureThumbVisibleCentered(idx){
772
+ for(let k=0; k<50; k++){
773
  const el = findThumbEl(idx);
774
  if(el){
775
  const img = el.querySelector('img');
776
  if(!img.complete || img.naturalWidth === 0){
777
+ await new Promise(r=>setTimeout(r, 30));
778
  }else{
779
  rawCenterThumb(el);
780
+ await new Promise(r=>requestAnimationFrame(()=>r()));
781
  updatePlayhead();
782
  return true;
783
  }
784
  }else{
785
+ await new Promise(r=>setTimeout(r, 30));
786
  }
787
  }
788
  return false;
 
830
  savePendingList(kept);
831
  if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
832
  }
 
 
 
 
 
 
 
 
 
833
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
834
  function fitCanvas(){
835
  const r=player.getBoundingClientRect();
 
851
  modeLabel.textContent='Édition';
852
  btnEdit.style.display='none'; btnBack.style.display='inline-block';
853
  btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
 
854
  canvas.style.pointerEvents='auto';
855
+ rect = rectMap.get(currentIdx) || null; draw();
 
856
  }else{
857
  player.controls = true;
858
  playerWrap.classList.remove('edit-mode');
859
  modeLabel.textContent='Lecture';
860
  btnEdit.style.display='inline-block'; btnBack.style.display='none';
861
  btnSave.style.display='none'; btnClear.style.display='none';
 
862
  canvas.style.pointerEvents='none';
863
+ rect=null; draw();
864
  }
865
  }
866
  function draw(){
867
  ctx.clearRect(0,0,canvas.width,canvas.height);
868
+ if(rect){
869
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
870
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
871
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
872
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
873
+ }
874
  }
875
  canvas.addEventListener('mousedown',(e)=>{
876
  if(mode!=='edit' || !vidName) return;
877
  dragging=true; const r=canvas.getBoundingClientRect();
878
  sx=e.clientX-r.left; sy=e.clientY-r.top;
879
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
880
  });
881
  canvas.addEventListener('mousemove',(e)=>{
882
  if(!dragging) return;
883
  const r=canvas.getBoundingClientRect();
884
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
885
  });
886
+ ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
887
+ btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  btnEdit.onclick =()=> setMode('edit');
889
  btnBack.onclick =()=> setMode('view');
890
+ // Palette
891
  palette.querySelectorAll('.swatch').forEach(el=>{
892
  if(el.dataset.c===color) el.classList.add('sel');
893
  el.onclick=()=>{
894
  palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
895
  el.classList.add('sel'); color=el.dataset.c;
896
+ if(rect){ rect.color=color; draw(); }
897
  };
898
  });
899
+ // === Timeline ===
900
  async function loadTimelineUrls(){
901
  timelineUrls = [];
902
  const stem = vidStem, b = bustToken;
903
  for(let idx=0; idx<frames; idx++){
904
+ timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`;
905
  }
906
  tlProgressFill.style.width='0%';
907
  }
 
916
  setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
917
  return;
918
  }
919
+ if (portionStart!=null && portionEnd!=null){
920
+ const s = Math.max(0, Math.min(portionStart, frames-1));
921
+ const e = Math.max(s+1, Math.min(frames, portionEnd)); // fin exclusive
922
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
923
+ for (let i = s; i < e; i++) addThumb(i, 'append'); // rendre TOUTE la portion
924
+ timelineStart = s;
925
+ timelineEnd = e;
926
+ viewRangeStart = s;
927
+ viewRangeEnd = e;
928
+ setTimeout(async ()=>{
929
+ syncTimelineWidth();
930
+ updateSelectedThumb();
931
+ await ensureThumbVisibleCentered(currentIdx);
932
+ loadingInd.style.display='none';
933
+ updatePlayhead();
934
+ updatePortionOverlays();
935
+ }, 0);
936
  return;
937
  }
938
  await loadWindow(centerIdx ?? currentIdx);
 
961
  const img=new Image(); img.title='frame '+(idx+1);
962
  img.src=timelineUrls[idx];
963
  img.onerror = () => {
964
+ const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`;
965
  img.onerror = null;
966
  img.src = fallback;
967
  img.onload = () => {
968
+ const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`;
969
  timelineUrls[idx] = nu;
970
  img.src = nu;
971
  img.onload = null;
 
974
  if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
975
  img.onclick=async ()=>{
976
  currentIdx=idx; player.currentTime=idxToSec(currentIdx);
977
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
978
  updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
979
  };
980
  wrap.appendChild(img);
 
985
  else{ tlBox.appendChild(wrap); }
986
  thumbEls.set(idx, wrap);
987
  }
988
+ // Scroll chunk (mode normal uniquement)
989
  tlBox.addEventListener('scroll', ()=>{
990
  if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
991
  updatePlayhead(); updatePortionOverlays();
 
1005
  }
1006
  updatePlayhead(); updatePortionOverlays();
1007
  });
1008
+ // Isoler & Boucle
1009
  isolerBoucle.onclick = async ()=>{
1010
+ const start = Math.max(0, parseInt(goFrame.value || '1',10) - 1);
1011
+ const endIn = parseInt(endPortion.value || '',10);
1012
+ if(!endPortion.value || endIn <= (start+1) || endIn > frames){ alert('Portion invalide (fin > début)'); return; }
1013
+ const endExclusive = Math.min(frames, endIn); // fin exclusive; "55" => inclut #55 (idx 54)
1014
+ portionStart = start;
1015
+ portionEnd = endExclusive;
1016
+ viewRangeStart = start; viewRangeEnd = endExclusive;
1017
  player.pause(); isPaused = true;
1018
  currentIdx = start; player.currentTime = idxToSec(start);
1019
  await renderTimeline(currentIdx);
 
1035
  resetFull.style.display='none';
1036
  clearInterval(loopInterval); updatePortionOverlays();
1037
  };
1038
+ // Drag IN/OUT
1039
  function attachHandleDrag(handle, which){
1040
  let draggingH=false;
1041
  function onMove(e){
 
1050
  window.addEventListener('mousemove', onMove);
1051
  window.addEventListener('mouseup', ()=>{ draggingH=false; });
1052
  }
1053
+ ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
1054
+ // Progress popup (thumbs)
1055
  async function showProgress(vidStem){
1056
  popup.style.display = 'block';
1057
  const interval = setInterval(async () => {
 
1059
  const d = await r.json();
1060
  tlProgressFill.style.width = d.percent + '%';
1061
  popupProgressFill.style.width = d.percent + '%';
1062
+ popupLogs.innerHTML = (d.logs||[]).map(x=>String(x)).join('<br>');
1063
  if(d.done){
1064
  clearInterval(interval);
1065
  popup.style.display = 'none';
 
1067
  }
1068
  }, 800);
1069
  }
1070
+ // Meta & boot
1071
  async function loadVideoAndMeta() {
1072
  if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1073
  vidStem = fileStem(vidName); bustToken = Date.now();
1074
  const bust = Date.now();
1075
+ srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
1076
+ player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
1077
  player.load();
1078
  fitCanvas();
1079
  statusEl.textContent = 'Chargement vidéo…';
1080
  try{
1081
+ const r=await fetch('/meta/'+encodeURIComponent(vidName));
1082
  if(r.ok){
1083
  const m=await r.json();
1084
  fps=m.fps||30; frames=m.frames||0;
 
1099
  player.addEventListener('loadedmetadata', async ()=>{
1100
  fitCanvas();
1101
  if(!frames || frames<=0){
1102
+ 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{}
1103
  }
1104
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
1105
  });
1106
  window.addEventListener('resize', ()=>{ fitCanvas(); });
1107
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
 
1109
  player.addEventListener('timeupdate', ()=>{
1110
  posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1111
  currentIdx=timeToIdx(player.currentTime);
1112
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1113
  updateHUD(); updateSelectedThumb(); updatePlayhead();
1114
  if(followMode && !isPaused){
1115
  const now = Date.now();
1116
  if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1117
  }
1118
  });
1119
+
1120
  goFrame.addEventListener('change', async ()=>{
1121
  if(!vidName) return;
1122
  const val=Math.max(1, parseInt(goFrame.value||'1',10));
1123
  player.pause(); isPaused = true;
1124
  currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1125
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1126
  await renderTimeline(currentIdx);
1127
  await ensureThumbVisibleCentered(currentIdx);
1128
  });
1129
+ // Follow / Filter / Zoom / Goto
1130
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1131
  btnFilterMasked.onclick = async ()=>{
1132
  maskedOnlyMode = !maskedOnlyMode;
 
1147
  }
1148
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1149
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1150
+ // Drag & drop upload
1151
  const uploadZone = document.getElementById('uploadForm');
1152
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1153
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
 
1159
  fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload());
1160
  }
1161
  });
1162
+ // Export placeholder
1163
+ document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1164
+ // Fichiers & masques
1165
  async function loadFiles(){
1166
  const r=await fetch('/files'); const d=await r.json();
1167
  if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
 
1181
  async function loadMasks(){
1182
  loadingInd.style.display='block';
1183
  const box=document.getElementById('maskList');
1184
+ const r=await fetch('/mask/'+encodeURIComponent(vidName));
1185
  const d=await r.json();
1186
  masks=d.masks||[];
1187
+ maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1188
  rectMap.clear();
1189
+ // simple: un seul rect "actif" par frame (on affiche le dernier enregistré)
1190
  masks.forEach(m=>{
 
 
 
1191
  if(m.shape==='rect'){
1192
  const [x1,y1,x2,y2] = m.points;
1193
  const normW = canvas.clientWidth, normH = canvas.clientHeight;
1194
+ rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1195
  }
1196
  });
1197
  maskedCount.textContent = `(${maskedSet.size} ⭐)`;
 
1226
  });
1227
  box.appendChild(ul);
1228
  loadingInd.style.display='none';
 
1229
  }
1230
+ // Save mask (+ cache)
1231
+ btnSave.onclick = async ()=>{
1232
+ if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1233
+ const defaultName = `frame ${currentIdx+1}`;
1234
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1235
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1236
+ const x=Math.min(rect.x1,rect.x2)/normW;
1237
+ const y=Math.min(rect.y1,rect.y2)/normH;
1238
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1239
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1240
+ 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};
1241
+ addPending(payload);
1242
+ try{
1243
+ const r=await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1244
+ if(r.ok){
1245
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1246
+ savePendingList(lst);
1247
+ rectMap.set(currentIdx,{...rect});
1248
+ await loadMasks(); await renderTimeline(currentIdx);
1249
+ showToast('Masque enregistré ✅');
1250
+ } else {
1251
+ const txt = await r.text();
1252
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1253
+ }
1254
+ }catch(e){
1255
+ alert('Erreur réseau lors de l’\u00e9nregistrement du masque.');
 
 
1256
  }
1257
  };
1258
+ // Warm-up button
1259
+ warmBtn.onclick = async ()=>{
1260
+ try{ await fetch('/warmup/start',{method:'POST'}); }catch{}
1261
+ const poll = async ()=>{
1262
+ try{
1263
+ const r = await fetch('/warmup/status');
1264
+ const st = await r.json();
1265
+ warmBar.style.width = (st.percent||0) + '%';
1266
+ let txt = (st.state||'idle');
1267
+ if(st.state==='running' && st.current){ txt += ' · ' + st.current; }
1268
+ if(st.state==='error'){ txt += ' · erreur'; }
1269
+ warmStat.textContent = txt;
1270
+ const lines = (st.log||[]).slice(-6);
1271
+ warmLog.innerHTML = lines.map(x=>String(x)).join('<br>');
1272
+ if(st.state==='done' || st.state==='error') return; else setTimeout(poll, 1000);
1273
+ }catch{ setTimeout(poll, 1500); }
1274
+ };
1275
+ poll();
1276
  };
1277
+ // Boot
1278
  async function boot(){
1279
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
 
 
 
1280
  await loadFiles();
1281
  if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1282
  else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
 
1283
  }
1284
  boot();
1285
+ // Hide controls in edit-mode
1286
  const style = document.createElement('style');
1287
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
1288
  document.head.appendChild(style);
1289
  </script>
1290
  </html>
1291
  """
1292
+
1293
+ @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1294
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1295
  vid = v or ""
1296
  try:
 
1298
  except Exception:
1299
  pass
1300
  html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1301
+ return HTMLResponse(content=html)