# app.py — Video Editor API (v0.5.9) good 2 multi rectangle # v0.5.9: # - Centrages "Aller à #" / "Frame #" 100% fiables (attend rendu + image chargée) # - /mask, /mask/rename, /mask/delete : Body(...) explicite => plus de 422 silencieux # - Bouton "Enregistrer masque" : erreurs visibles (alert) si l’API ne répond pas OK from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pathlib import Path from typing import Optional, Dict, Any import uuid, shutil, cv2, json, time, urllib.parse, sys import threading # <<<< HUGINFACE PATCH: WARMUP IMPORTS START >>>> from typing import List from huggingface_hub import snapshot_download # <<<< HUGINFACE PATCH: WARMUP IMPORTS END >>>> import subprocess import shutil as _shutil # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------ import os import httpx print("[BOOT] Video Editor API starting…") POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip() FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip() print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}") print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}") _backend_url_cache = {"url": None, "ts": 0.0} # <<< IMPORTANT : créer l'app AVANT tout décorateur @app.* >>> app = FastAPI(title="Video Editor API", version="0.5.9") # --- Instance ID (utile si plusieurs répliques derrière un proxy) ------------- INSTANCE_ID = os.getenv("INSTANCE_ID", uuid.uuid4().hex[:6]) @app.middleware("http") async def inject_instance_id_header(request: Request, call_next): resp = await call_next(request) try: resp.headers["x-instance-id"] = INSTANCE_ID except Exception: pass return resp # ------------------------------------------------------------------------------ def get_backend_base() -> str: try: if POINTER_URL: now = time.time() need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30) if need_refresh: r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True) url = (r.text or "").strip() if url.startswith("http"): _backend_url_cache["url"] = url _backend_url_cache["ts"] = now else: return FALLBACK_BASE return _backend_url_cache["url"] or FALLBACK_BASE return FALLBACK_BASE except Exception: return FALLBACK_BASE DATA_DIR = Path("/app/data") THUMB_DIR = DATA_DIR / "_thumbs" MASK_DIR = DATA_DIR / "_masks" for p in (DATA_DIR, THUMB_DIR, MASK_DIR): p.mkdir(parents=True, exist_ok=True) app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data") app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs") # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) -------------------- @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"]) async def proxy_all(full_path: str, request: Request): base = get_backend_base().rstrip("/") target = f"{base}/{full_path}" qs = request.url.query if qs: target = f"{target}?{qs}" body = await request.body() headers = dict(request.headers) headers.pop("host", None) async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: r = await client.request(request.method, target, headers=headers, content=body) drop = {"content-encoding","transfer-encoding","connection", "keep-alive","proxy-authenticate","proxy-authorization", "te","trailers","upgrade"} out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop} return Response(content=r.content, status_code=r.status_code, headers=out_headers) # ------------------------------------------------------------------------------- # Global progress dict (vid_stem -> {percent, logs, done}) progress_data: Dict[str, Dict[str, Any]] = {} # <<<< HUGINFACE PATCH: WARMUP STATE+HELPERS START >>>> # État global du warm-up (progression, logs, etc.) warmup_state: Dict[str, Any] = { "running": False, "percent": 0, "logs": [], "done": False, "current": None, "total": 0, "idx": 0, "job_id": "default", } warmup_lock = threading.Lock() warmup_stop = threading.Event() def _default_model_list() -> List[str]: """ Liste par défaut lue depuis l'env WARMUP_MODELS (JSON array) sinon vide. Exemple env: WARMUP_MODELS=["runwayml/stable-diffusion-v1-5","facebook/sam2-hiera-base"] """ env = (os.getenv("WARMUP_MODELS") or "").strip() if env: try: lst = json.loads(env) if isinstance(lst, list): return [str(x).strip() for x in lst if str(x).strip()] except Exception: pass return [] def _log_warmup(msg: str): print(f"[WARMUP] {msg}", file=sys.stdout) with warmup_lock: warmup_state["logs"].append(msg) if len(warmup_state["logs"]) > 400: warmup_state["logs"] = warmup_state["logs"][-400:] def _download_one(repo_id: str, tries: int = 3) -> bool: """ Télécharge un repo HF en réessayant si besoin. """ cache_home = os.path.expanduser(os.getenv("HF_HOME", "/home/user/.cache/huggingface")) local_dir = os.path.join(cache_home, "models", repo_id.replace("/", "__")) # Déjà présent localement ? On log et on saute proprement. try: if _is_repo_cached(repo_id): _log_warmup(f"Déjà en cache: {repo_id} (skip)") return True except Exception: # En cas d'erreur d'audit, on continue le process normal pass for attempt in range(1, tries + 1): if warmup_stop.is_set(): return False try: snapshot_download( repo_id, local_dir=local_dir, local_dir_use_symlinks=False, resume_download=True, ) return True except Exception as e: _log_warmup(f"{repo_id} -> tentative {attempt}/{tries} échouée: {e}") time.sleep(min(10, 2 * attempt)) return False def _warmup_thread(models: List[str]): with warmup_lock: warmup_state.update({ "running": True, "percent": 0, "ok_count": 0, "done": False, "logs": [], "current": None, "total": len(models), "idx": 0, }) warmup_stop.clear() ok_count = 0 for i, repo in enumerate(models): if warmup_stop.is_set(): _log_warmup("Arrêt demandé par l’utilisateur.") break with warmup_lock: warmup_state["idx"] = i warmup_state["current"] = repo warmup_state["percent"] = int((i / max(1, len(models))) * 100) _log_warmup(f"Téléchargement: {repo}") ok = _download_one(repo) if ok: ok_count += 1 _log_warmup(f"OK: {repo}") with warmup_lock: warmup_state["ok_count"] = ok_count else: _log_warmup(f"ÉCHEC: {repo}") # Met à jour la progression globale après ce repo (ok ou échec) with warmup_lock: warmup_state["percent"] = int(((i + 1) / max(1, len(models))) * 100) with warmup_lock: warmup_state["percent"] = 100 warmup_state["done"] = True warmup_state["running"] = False warmup_state["ok_count"] = ok_count _log_warmup(f"Terminé: {ok_count}/{len(models)} modèles.") # <<<< HUGINFACE PATCH: WARMUP STATE+HELPERS END >>>> # ---------- Helpers ---------- def _is_video(p: Path) -> bool: return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"} def _safe_name(name: str) -> str: return Path(name).name.replace(" ", "_") def _has_ffmpeg() -> bool: return _shutil.which("ffmpeg") is not None def _ffmpeg_scale_filter(max_w: int = 320) -> str: return f"scale=min(iw\\,{max_w}):-2" def _meta(video: Path): cap = cv2.VideoCapture(str(video)) if not cap.isOpened(): print(f"[META] OpenCV cannot open: {video}", file=sys.stdout) return None frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0 w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) cap.release() print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout) return {"frames": frames, "fps": fps, "w": w, "h": h} def _frame_jpg(video: Path, idx: int) -> Path: out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg" if out.exists(): return out if _has_ffmpeg(): m = _meta(video) or {"fps": 30.0} fps = float(m.get("fps") or 30.0) or 30.0 t = max(0.0, float(idx) / fps) cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{t:.6f}", "-i", str(video), "-frames:v", "1", "-vf", _ffmpeg_scale_filter(320), "-q:v", "8", str(out) ] try: subprocess.run(cmd, check=True) return out except subprocess.CalledProcessError as e: print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout) cap = cv2.VideoCapture(str(video)) if not cap.isOpened(): print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout) raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.") total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) if total <= 0: cap.release() print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout) raise HTTPException(500, "Frame count invalide.") idx = max(0, min(idx, total - 1)) cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ok, img = cap.read() cap.release() if not ok or img is None: print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout) raise HTTPException(500, "Impossible de lire la frame demandée.") h, w = img.shape[:2] if w > 320: new_w = 320 new_h = int(h * (320.0 / w)) or 1 img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR)) cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) return out def _poster(video: Path) -> Path: out = THUMB_DIR / f"poster_{video.stem}.jpg" if out.exists(): return out try: cap = cv2.VideoCapture(str(video)) cap.set(cv2.CAP_PROP_POS_FRAMES, 0) ok, img = cap.read() cap.release() if ok and img is not None: cv2.imwrite(str(out), img) except Exception as e: print(f"[POSTER] Failed: {e}", file=sys.stdout) return out def _mask_file(vid: str) -> Path: return MASK_DIR / f"{Path(vid).name}.json" def _load_masks(vid: str) -> Dict[str, Any]: f = _mask_file(vid) if f.exists(): try: return json.loads(f.read_text(encoding="utf-8")) except Exception as e: print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout) return {"video": vid, "masks": []} def _save_masks(vid: str, data: Dict[str, Any]): _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def _gen_thumbs_background(video: Path, vid_stem: str): progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False} try: m = _meta(video) if not m: progress_data[vid_stem]['logs'].append("Erreur métadonnées") progress_data[vid_stem]['done'] = True return total_frames = int(m["frames"] or 0) if total_frames <= 0: progress_data[vid_stem]['logs'].append("Aucune frame détectée") progress_data[vid_stem]['done'] = True return for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"): f.unlink(missing_ok=True) if _has_ffmpeg(): out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg") cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(video), "-vf", _ffmpeg_scale_filter(320), "-q:v", "8", "-start_number", "0", out_tpl ] progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…") proc = subprocess.Popen(cmd) last_report = -1 while proc.poll() is None: generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg"))) percent = int(min(99, (generated / max(1, total_frames)) * 100)) progress_data[vid_stem]['percent'] = percent if generated != last_report and generated % 50 == 0: progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}") last_report = generated time.sleep(0.4) proc.wait() generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg"))) progress_data[vid_stem]['percent'] = 100 progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames)) progress_data[vid_stem]['done'] = True print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout) else: progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…") cap = cv2.VideoCapture(str(video)) if not cap.isOpened(): progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.") progress_data[vid_stem]['done'] = True return idx = 0 last_report = -1 while True: ok, img = cap.read() if not ok or img is None: break out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg" h, w = img.shape[:2] if w > 320: new_w = 320 new_h = int(h * (320.0 / w)) or 1 img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR)) cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) idx += 1 if idx % 50 == 0: progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100)) if idx != last_report: progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}") last_report = idx cap.release() progress_data[vid_stem]['percent'] = 100 progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs") progress_data[vid_stem]['done'] = True print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout) except Exception as e: progress_data[vid_stem]['logs'].append(f"Erreur: {e}") progress_data[vid_stem]['done'] = True # ---------- API ---------- @app.get("/", tags=["meta"]) def root(): 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/start", "/warmup/status", "/warmup/stop", "/warmup/audit"] } @app.get("/health", tags=["meta"]) def health(): return {"status": "ok"} @app.get("/_env", tags=["meta"]) def env_info(): return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()} @app.get("/files", tags=["io"]) def files(): items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)] return {"count": len(items), "items": items} @app.get("/meta/{vid}", tags=["io"]) def video_meta(vid: str): v = DATA_DIR / vid if not v.exists(): raise HTTPException(404, "Vidéo introuvable") m = _meta(v) if not m: raise HTTPException(500, "Métadonnées indisponibles") return m @app.post("/upload", tags=["io"]) async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True): ext = (Path(file.filename).suffix or ".mp4").lower() if ext not in {".mp4", ".mov", ".mkv", ".webm"}: raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm") base = _safe_name(file.filename) dst = DATA_DIR / base if dst.exists(): dst = DATA_DIR / f"{Path(base).stem}__{uuid.uuid4().hex[:8]}{ext}" with dst.open("wb") as f: shutil.copyfileobj(file.file, f) print(f"[UPLOAD] Saved {dst.name} ({dst.stat().st_size} bytes)", file=sys.stdout) _poster(dst) stem = dst.stem threading.Thread(target=_gen_thumbs_background, args=(dst, stem), daemon=True).start() accept = (request.headers.get("accept") or "").lower() if redirect or "text/html" in accept: msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…") return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303) return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True} @app.get("/progress/{vid_stem}", tags=["io"]) def progress(vid_stem: str): return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False}) @app.delete("/delete/{vid}", tags=["io"]) def delete_video(vid: str): v = DATA_DIR / vid if not v.exists(): raise HTTPException(404, "Vidéo introuvable") (THUMB_DIR / f"poster_{v.stem}.jpg").unlink(missing_ok=True) for f in THUMB_DIR.glob(f"f_{v.stem}_*.jpg"): f.unlink(missing_ok=True) _mask_file(vid).unlink(missing_ok=True) v.unlink(missing_ok=True) print(f"[DELETE] {vid}", file=sys.stdout) return {"deleted": vid} @app.get("/frame_idx", tags=["io"]) def frame_idx(vid: str, idx: int): v = DATA_DIR / vid if not v.exists(): raise HTTPException(404, "Vidéo introuvable") try: out = _frame_jpg(v, int(idx)) print(f"[FRAME] OK {vid} idx={idx}", file=sys.stdout) return FileResponse(str(out), media_type="image/jpeg") except HTTPException as he: print(f"[FRAME] FAIL {vid} idx={idx}: {he.detail}", file=sys.stdout) raise except Exception as e: print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout) raise HTTPException(500, "Frame error") @app.get("/poster/{vid}", tags=["io"]) def poster(vid: str): v = DATA_DIR / vid if not v.exists(): raise HTTPException(404, "Vidéo introuvable") p = _poster(v) if p.exists(): return FileResponse(str(p), media_type="image/jpeg") raise HTTPException(404, "Poster introuvable") # >>> B1_BEGIN warmup_routes from copy import deepcopy @app.post("/warmup/start", tags=["warmup"]) async def warmup_start(payload: Optional[Dict[str, Any]] = Body(None)): """ Démarre le téléchargement séquentiel des modèles. Corps JSON optionnel: { "models": ["repo1","repo2", ...] } Si vide: lit la variable d'env WARMUP_MODELS (JSON). """ models = [] if payload and isinstance(payload, dict): models = [str(x).strip() for x in (payload.get("models") or []) if str(x).strip()] if not models: models = _default_model_list() # Dé-doublonnage en conservant l'ordre seen = set() models = [m for m in models if not (m in seen or seen.add(m))] if not models: raise HTTPException(400, "Aucun modèle fourni (payload.models) et WARMUP_MODELS vide") with warmup_lock: if warmup_state.get("running"): # Déjà en cours : on renvoie l'état actuel pour info return { "ok": False, "already_running": True, "status": deepcopy(warmup_state), } # Lancer un nouveau thread job_id = uuid.uuid4().hex[:8] warmup_state["job_id"] = job_id th = threading.Thread(target=_warmup_thread, args=(models,), daemon=True) th.start() return {"ok": True, "started": True, "total": len(models), "job_id": job_id} @app.get("/warmup/status", tags=["warmup"]) def warmup_status(): """Retourne l'état courant du warm-up (progression, logs, etc.).""" # On copie l'état sous verrou pour éviter les lectures concurrentes with warmup_lock: data = deepcopy(warmup_state) data["instance_id"] = INSTANCE_ID # aide au diagnostic multi-répliques # Infos d’audit du cache (hors verrou) cached = _list_cached_repos() data["audit_count"] = len(cached) data["audit_cached"] = cached data["ts"] = time.time() return data @app.post("/warmup/stop", tags=["warmup"]) def warmup_stop_api(): """Demande l'arrêt propre (on termine le modèle en cours puis on stoppe).""" with warmup_lock: running = bool(warmup_state.get("running")) if running: warmup_stop.set() return {"ok": True, "was_running": True} return {"ok": True, "was_running": False} # >>> B1_END warmup_routes # >>> B2_BEGIN warmup_audit def _hf_cache_dir() -> str: return os.path.expanduser(os.getenv("HF_HOME", "/home/user/.cache/huggingface")) def _is_repo_cached(repo_id: str) -> bool: local_dir = os.path.join(_hf_cache_dir(), "models", repo_id.replace("/", "__")) try: return os.path.isdir(local_dir) and any(os.scandir(local_dir)) except FileNotFoundError: return False def _list_cached_repos() -> List[str]: base = os.path.join(_hf_cache_dir(), "models") out: List[str] = [] try: for e in os.scandir(base): if e.is_dir(): out.append(e.name.replace("__", "/")) except FileNotFoundError: pass return sorted(out) @app.get("/warmup/audit", tags=["warmup"]) def warmup_audit(): """ Retourne l'état du cache HF côté serveur. - cached: tous les repos détectés localement - present_default / missing_default: croisement avec WARMUP_MODELS (si défini) """ cached = _list_cached_repos() defaults = _default_model_list() present_default = [r for r in defaults if r in cached] missing_default = [r for r in defaults if r not in cached] return { "count": len(cached), "cached": cached, "present_default": present_default, "missing_default": missing_default, } @app.get("/warmup/catalog", tags=["warmup"]) def warmup_catalog(): try: env = (os.getenv("WARMUP_MODELS") or "").strip() base = [] if env: try: lst = json.loads(env) if isinstance(lst, list) and lst: base = [str(x).strip() for x in lst if str(x).strip()] except Exception: base = [] except Exception: base = [] if not base: base = [ "runwayml/stable-diffusion-v1-5", "facebook/sam-vit-base", "stabilityai/sd-vae-ft-mse", "lixiaowen/diffuEraser", "facebook/sam2-hiera-base", ] seen = set() out = [m for m in base if not (m in seen or seen.add(m))] return {"count": len(out), "models": out} # >>> A1_BEGIN window_fix @app.get("/window/{vid}", tags=["io"]) def window(vid: str, center: int = 0, count: int = 21): v = DATA_DIR / vid if not v.exists(): raise HTTPException(404, "Vidéo introuvable") m = _meta(v) if not m: raise HTTPException(500, "Métadonnées indisponibles") frames = int(m.get("frames") or 0) count = max(3, int(count)) center = max(0, min(int(center), max(0, frames - 1))) if frames <= 0: print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout) return { "vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0, } if frames <= count: start = 0 sel = center n = frames else: start = max(0, min(center - (count // 2), frames - count)) n = count sel = center - start items = [] bust = int(time.time() * 1000) for i in range(n): idx = start + i url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}" items.append({"i": i, "idx": idx, "url": url}) print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout) return { "vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames, } # >>> A1_END window_fix # ----- Masques ----- @app.post("/mask", tags=["mask"]) async def save_mask(payload: Dict[str, Any] = Body(...)): vid = payload.get("vid") if not vid: raise HTTPException(400, "vid manquant") pts = payload.get("points") or [] if len(pts) != 4: raise HTTPException(400, "points rect (x1,y1,x2,y2) requis") data = _load_masks(vid) m = { "id": uuid.uuid4().hex[:10], "time_s": float(payload.get("time_s") or 0.0), "frame_idx": int(payload.get("frame_idx") or 0), "shape": "rect", "points": [float(x) for x in pts], "color": payload.get("color") or "#10b981", "note": payload.get("note") or "" } data.setdefault("masks", []).append(m) _save_masks(vid, data) print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout) return {"saved": True, "mask": m} @app.get("/mask/{vid}", tags=["mask"]) def list_masks(vid: str): return _load_masks(vid) @app.post("/mask/rename", tags=["mask"]) async def rename_mask(payload: Dict[str, Any] = Body(...)): vid = payload.get("vid") mid = payload.get("id") new_note = (payload.get("note") or "").strip() if not vid or not mid: raise HTTPException(400, "vid et id requis") data = _load_masks(vid) for m in data.get("masks", []): if m.get("id") == mid: m["note"] = new_note _save_masks(vid, data) return {"ok": True} raise HTTPException(404, "Masque introuvable") @app.post("/mask/delete", tags=["mask"]) async def delete_mask(payload: Dict[str, Any] = Body(...)): vid = payload.get("vid") mid = payload.get("id") if not vid or not mid: raise HTTPException(400, "vid et id requis") data = _load_masks(vid) data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid] _save_masks(vid, data) return {"ok": True} # ---------- UI (multi-rects par frame) ---------- HTML_TEMPLATE = r""" Video Editor

🎬 Video Editor

Charger une vidéo :
__MSG__ Liens : /docs/files

Timeline

Chargement des frames...
Mode : Lecture
Couleur
Masques
Vidéos disponibles
    Chargement…
// >>> A2B2_BEGIN warmup_dom // Références DOM (topbar + popup) const warmupStartBtn = document.getElementById('warmupStartBtn'); const warmupStopBtn = document.getElementById('warmupStopBtn'); const warmupLogsBtn = document.getElementById('warmupLogsBtn'); const warmupStatusEl = document.getElementById('warmupStatus'); const warmupPopup = document.getElementById('warmupPopup'); const warmupPopupStatus = document.getElementById('warmupPopupStatus'); const warmupProgressFill = document.getElementById('warmup-progress-fill'); const warmupLogs = document.getElementById('warmup-logs'); // Mémorise la dernière instance vue pour détecter les bascules window._lastInstanceId = null; const warmupCloseBtn = document.getElementById('warmupCloseBtn'); // Helpers popup // >>> C2_BEGIN warmup_preface let warmupPreface = ''; // >>> C2_END warmup_preface // >>> A2B2P3_BEGIN warmup_defaults // Modèles par défaut si l’utilisateur laisse la saisie vide. // (Liste “safe” validée chez toi ; on pourra l’étendre plus tard.) const DEFAULT_MODELS = [ "runwayml/stable-diffusion-v1-5", "facebook/sam-vit-base", "stabilityai/sd-vae-ft-mse" ]; // >>> A2B2P3_END warmup_defaults function openWarmupPopup(){ if(warmupPopup) warmupPopup.style.display = 'block'; } function closeWarmupPopup(){ if(warmupPopup) warmupPopup.style.display = 'none'; } if (warmupCloseBtn) warmupCloseBtn.addEventListener('click', closeWarmupPopup); // Rafraîchissement d’état let warmupTimer = null; async function refreshWarmupUI(){ try{ const r = await fetch('/warmup/status'); if(!r.ok) return; const s = await r.json(); // Instance courante (ajout backend) const instanceId = s.instance_id || 'n/a'; if (window._lastInstanceId && window._lastInstanceId !== instanceId) { showToast(`Bascule d'instance détectée : ${window._lastInstanceId} → ${instanceId}`); } window._lastInstanceId = instanceId; const pct = Math.max(0, Math.min(100, parseInt(s.percent||0,10))); const running = !!s.running; if (warmupStatusEl) { const tot = (s.total ?? 0); if (running) { const idx = (s.idx ?? 0) + 1; warmupStatusEl.textContent = `⏳ ${pct}% — ${s.current||''} (${idx}/${tot}) [inst:${instanceId}]`; } else { // Détection fin de run (par job_id) => toast récap une seule fois if (s.done && s.job_id && window._lastNotifiedJobId !== s.job_id) { const tot2 = (s.total ?? 0); const ok2 = Number.isFinite(s.ok_count) ? s.ok_count : '—'; showToast(`Warm-up terminé — ${ok2}/${tot2} téléchargés (inst:${instanceId})`); window._lastNotifiedJobId = s.job_id; } if (s.done && Number.isFinite(s.ok_count)) { warmupStatusEl.textContent = `✅ Terminé — ${s.ok_count}/${tot} téléchargés (inst:${instanceId})`; } else { const nCache = Number.isFinite(s.audit_count) ? s.audit_count : (Array.isArray(s.audit_cached) ? s.audit_cached.length : 0); if (nCache > 0) { warmupStatusEl.textContent = `✅ Prêt — cache local: ${nCache} (inst:${instanceId})`; } else { warmupStatusEl.textContent = `Prêt (aucun run)`; } } } } if (warmupProgressFill) warmupProgressFill.style.width = pct + '%'; if (warmupPopupStatus) warmupPopupStatus.textContent = running ? 'Téléchargement en cours…' : 'Terminé'; // PRÉAMBULE FIXE (conservé pendant tout le run) // (Auto-scroll sur chaque rafraîchissement) const logsTxt = Array.isArray(s.logs) ? s.logs.join('\n') : ''; const fixedPreface = (warmupPreface && warmupPreface.trim().length) ? warmupPreface : (()=>{ const n = Number.isFinite(s.audit_count) ? s.audit_count : (Array.isArray(s.audit_cached) ? s.audit_cached.length : 0); const cachedList = Array.isArray(s.audit_cached) ? s.audit_cached : []; const asked = Array.isArray(window.lastRequestedModels) ? window.lastRequestedModels : []; return ( `[Instance ${instanceId}]` + '\n' + 'Déjà en cache (' + n + '):\n' + cachedList.map(m => ' • ' + m).join('\n') + '\n\n' + 'Demandé dans cette exécution (' + asked.length + '):\n' + asked.map(m => ' • ' + m).join('\n') ); })(); if (warmupLogs) { warmupLogs.textContent = fixedPreface + '\n\n' + logsTxt; warmupLogs.scrollTop = warmupLogs.scrollHeight; } if (warmupStopBtn) warmupStopBtn.style.display = running ? 'inline-block' : 'none'; if (running) { openWarmupPopup(); if (!warmupTimer) warmupTimer = setInterval(refreshWarmupUI, 1000); } else { if (warmupTimer) { clearInterval(warmupTimer); warmupTimer = null; } } }catch(e){ /* no-op */ } } // Boutons if (warmupStartBtn){ warmupStartBtn.addEventListener('click', async ()=>{ // Saisie optionnelle — accepte virgules, espaces, retours à la ligne, points-virgules const txt = prompt( 'Modèles (optionnel). Tu peux séparer par virgule, espace ou retour à la ligne.\n' + 'Laisse vide pour utiliser la liste par défaut.', '' ); let models; if (txt && txt.trim()){ // Découpe souple + nettoyage + dédoublonnage const mods = txt.split(/[\s,;]+/).map(s=>s.trim()).filter(Boolean); if (!mods.length){ alert('Aucun modèle détecté.'); return; } models = Array.from(new Set(mods)); } else { // Liste par défaut définie plus haut (P4.1) models = DEFAULT_MODELS; } // Mémorise la demande en cours pour l’affichage des logs window.lastRequestedModels = models; // Préface (audit du cache serveur, pour afficher le cumul) try { const ra = await fetch('/warmup/audit'); const audit = ra.ok ? await ra.json() : null; const cached = (audit && Array.isArray(audit.cached)) ? audit.cached : []; warmupPreface = 'Déjà en cache (' + cached.length + '):\n' + cached.map(m => ' • ' + m).join('\n') + '\n\n' + 'Demandé dans cette exécution (' + models.length + '):\n' + models.map(m => ' • ' + m).join('\n'); } catch(e) { warmupPreface = ''; } // Ouvre la popup et affiche immédiatement la liste demandée openWarmupPopup(); if (warmupPopupStatus) warmupPopupStatus.textContent = 'Préparation en cours…'; if (warmupLogs) { warmupLogs.textContent = (warmupPreface ? warmupPreface + '\n' : '') + '— démarrage…'; warmupLogs.scrollTop = warmupLogs.scrollHeight; // auto-scroll vers le bas } // Sécurité : évite 2 timers concurrents if (warmupTimer) { clearInterval(warmupTimer); warmupTimer = null; } try{ const r = await fetch('/warmup/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ models }) }); if(!r.ok){ const t = await r.text(); alert('Échec démarrage: ' + r.status + ' ' + t); return; } // Si un warm-up tourne déjà, on l'indique et on passe en mode "suivi" try { const payload = await r.json(); if (payload && payload.already_running) { showToast("Un warm-up est déjà en cours — j'affiche l'état."); } } catch(e) { /* no-op */ } // Rafraîchit l’UI et démarre le polling await refreshWarmupUI(); if (!warmupTimer) warmupTimer = setInterval(refreshWarmupUI, 1000); }catch(e){ alert('Erreur réseau'); } }); } if (warmupLogsBtn){ warmupLogsBtn.addEventListener('click', async ()=>{ // Recalcule une préface à la volée (hors exécution) try{ const ra = await fetch('/warmup/audit'); const audit = ra.ok ? await ra.json() : null; const cached = (audit && Array.isArray(audit.cached)) ? audit.cached : []; if (!warmupPreface) { warmupPreface = 'Déjà en cache (' + cached.length + '):\n' + cached.map(m => ' • ' + m).join('\n'); } }catch(e){} openWarmupPopup(); await refreshWarmupUI(); if (!warmupTimer) warmupTimer = setInterval(refreshWarmupUI, 1000); }); } if (warmupStopBtn){ warmupStopBtn.addEventListener('click', async ()=>{ try{ await fetch('/warmup/stop',{method:'POST'}); await refreshWarmupUI(); }catch(e){} }); } // 1er affichage (badge de statut en topbar) refreshWarmupUI(); // >>> A2B2_END warmup_dom const warmupSelectBtn = document.getElementById('warmupSelectBtn'); const warmupSelectPopup = document.getElementById('warmupSelectPopup'); const repoListEl = document.getElementById('repoList'); const selectCancelBtn = document.getElementById('selectCancelBtn'); const selectAllBtn = document.getElementById('selectAllBtn'); const selectNoneBtn = document.getElementById('selectNoneBtn'); const launchSelectedBtn = document.getElementById('launchSelectedBtn'); function openSelect(){ if(warmupSelectPopup) warmupSelectPopup.style.display='block'; } function closeSelect(){ if(warmupSelectPopup) warmupSelectPopup.style.display='none'; } async function loadCatalog(){ if(!repoListEl) return; repoListEl.innerHTML = '
Chargement…
'; try{ const r = await fetch('/warmup/catalog'); if(!r.ok){ repoListEl.innerHTML = '
Erreur de chargement.
'; return; } const d = await r.json(); const items = Array.isArray(d.models) ? d.models : []; if(!items.length){ repoListEl.innerHTML = '
Aucun modèle proposé.
'; return; } repoListEl.innerHTML = ''; items.forEach(id=>{ const row = document.createElement('div'); row.className='repo-item'; const cb = document.createElement('input'); cb.type='checkbox'; cb.value=id; const label = document.createElement('span'); label.className='repo-id'; label.textContent = id; row.appendChild(cb); row.appendChild(label); repoListEl.appendChild(row); }); }catch(e){ repoListEl.innerHTML = '
Erreur réseau.
'; } } if (warmupSelectBtn){ warmupSelectBtn.addEventListener('click', async ()=>{ await loadCatalog(); openSelect(); }); } if (selectCancelBtn){ selectCancelBtn.addEventListener('click', closeSelect); } if (selectAllBtn){ selectAllBtn.addEventListener('click', ()=>{ repoListEl.querySelectorAll('input[type="checkbox"]').forEach(cb=> cb.checked=true); }); } if (selectNoneBtn){ selectNoneBtn.addEventListener('click', ()=>{ repoListEl.querySelectorAll('input[type="checkbox"]').forEach(cb=> cb.checked=false); }); } if (launchSelectedBtn){ launchSelectedBtn.addEventListener('click', async ()=>{ const picks = Array.from(repoListEl.querySelectorAll('input[type="checkbox"]')) .filter(cb=>cb.checked).map(cb=>cb.value); if(!picks.length){ alert('Sélectionne au moins un modèle.'); return; } window.lastRequestedModels = picks.slice(); if (typeof openWarmupPopup === 'function') openWarmupPopup(); if (typeof refreshWarmupUI === 'function') await refreshWarmupUI(); try{ const r = await fetch('/warmup/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ models: picks }) }); if(!r.ok){ const t = await r.text(); alert('Échec démarrage: ' + r.status + ' ' + t); return; } if (typeof refreshWarmupUI === 'function') { const id = setInterval(async ()=>{ await refreshWarmupUI(); try{ const s = await (await fetch('/warmup/status')).json(); if (!s.running) clearInterval(id); }catch(e){ clearInterval(id); } }, 1000); } closeSelect(); }catch(e){ alert('Erreur réseau.'); } }); } const gotoBtn = document.getElementById('gotoBtn'); // State let vidName = serverVid || ''; function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; } let vidStem = ''; let bustToken = Date.now(); let fps = 30, frames = 0; let currentIdx = 0; let mode = 'view'; // ==== Multi-rects ==== let currentDraw = null; // rect en cours de tracé let rectsMap = new Map(); // frame_idx -> Array let color = '#10b981'; // autres états inchangés let masks = []; let maskedSet = new Set(); let timelineUrls = []; let portionStart = null; let portionEnd = null; let loopInterval = null; let chunkSize = 50; let timelineStart = 0, timelineEnd = 0; let viewRangeStart = 0, viewRangeEnd = 0; const scrollThreshold = 100; let followMode = false; let isPaused = true; let thumbEls = new Map(); let lastCenterMs = 0; const CENTER_THROTTLE_MS = 150; const PENDING_KEY = 've_pending_masks_v1'; let maskedOnlyMode = false; // Utils function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); } function ensureOverlays(){ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); } if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); } if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); } if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); } } function playheadEl(){ return document.getElementById('playhead'); } function portionBand(){ return document.getElementById('portionBand'); } function inHandle(){ return document.getElementById('inHandle'); } function outHandle(){ return document.getElementById('outHandle'); } function findThumbEl(idx){ return thumbEls.get(idx) || null; } function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; } function updateSelectedThumb(){ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); }); const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img'); img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); } function rawCenterThumb(el){ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2); } async function ensureThumbVisibleCentered(idx){ for(let k=0; k<40; k++){ const el = findThumbEl(idx); if(el){ const img = el.querySelector('img'); if(!img.complete || img.naturalWidth === 0){ await new Promise(r=>setTimeout(r, 25)); }else{ rawCenterThumb(el); updatePlayhead(); return true; } }else{ await new Promise(r=>setTimeout(r, 25)); } } return false; } function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); } function updatePlayhead(){ ensureOverlays(); const el = findThumbEl(currentIdx); const ph = playheadEl(); if(!el){ ph.style.display='none'; return; } ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px'; } function updatePortionOverlays(){ ensureOverlays(); const pb = portionBand(), ih = inHandle(), oh = outHandle(); if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; } const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1); if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; } const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth; pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px'; ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px'; oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px'; } function nearestFrameIdxFromClientX(clientX){ const rect = tlBox.getBoundingClientRect(); const xIn = clientX - rect.left + tlBox.scrollLeft; let bestIdx = currentIdx, bestDist = Infinity; for(const [idx, el] of thumbEls.entries()){ const mid = el.offsetLeft + el.clientWidth/2; const d = Math.abs(mid - xIn); if(d < bestDist){ bestDist = d; bestIdx = idx; } } return bestIdx; } function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } } function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); } function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); } async function flushPending(){ const lst = loadPending(); if(!lst.length) return; const kept = []; for(const p of lst){ try{ const r = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); } catch{ kept.push(p); } } savePendingList(kept); if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅'); } // Layout function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; } function fitCanvas(){ const r=player.getBoundingClientRect(); const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH')); canvas.width=Math.round(r.width); canvas.height=Math.round(r.height - ctrlH); canvas.style.width=r.width+'px'; canvas.style.height=(r.height - ctrlH)+'px'; syncTimelineWidth(); } function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); } function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; } // ==== Multi-rects helpers ==== function getRectsForFrame(fi){ let arr = rectsMap.get(fi); if(!arr){ arr = []; rectsMap.set(fi, arr); } return arr; } function draw(){ ctx.clearRect(0,0,canvas.width,canvas.height); const arr = getRectsForFrame(currentIdx); for(const r of arr){ const x=Math.min(r.x1,r.x2), y=Math.min(r.y1,r.y2); const w=Math.abs(r.x2-r.x1), h=Math.abs(r.y2-r.y1); ctx.strokeStyle=r.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.fillStyle=(r.color||color)+'28'; ctx.fillRect(x,y,w,h); } if(currentDraw){ const x=Math.min(currentDraw.x1,currentDraw.x2), y=Math.min(currentDraw.y1,currentDraw.y2); const w=Math.abs(currentDraw.x2-currentDraw.x1), h=Math.abs(currentDraw.y2-currentDraw.y1); ctx.setLineDash([6,4]); ctx.strokeStyle=currentDraw.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h); ctx.setLineDash([]); } } function setMode(m){ mode=m; if(m==='edit'){ player.pause(); player.controls = false; playerWrap.classList.add('edit-mode'); modeLabel.textContent='Édition'; btnEdit.style.display='none'; btnBack.style.display='inline-block'; btnSave.style.display='inline-block'; btnClear.style.display='inline-block'; canvas.style.pointerEvents='auto'; draw(); }else{ player.controls = true; playerWrap.classList.remove('edit-mode'); modeLabel.textContent='Lecture'; btnEdit.style.display='inline-block'; btnBack.style.display='none'; btnSave.style.display='none'; btnClear.style.display='none'; canvas.style.pointerEvents='none'; currentDraw=null; draw(); } } // Dessin multi-rectangles let dragging=false, sx=0, sy=0; canvas.addEventListener('mousedown',(e)=>{ if(mode!=='edit' || !vidName) return; dragging=true; const r=canvas.getBoundingClientRect(); sx=e.clientX-r.left; sy=e.clientY-r.top; currentDraw={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw(); }); canvas.addEventListener('mousemove',(e)=>{ if(!dragging) return; const r=canvas.getBoundingClientRect(); currentDraw.x2=e.clientX-r.left; currentDraw.y2=e.clientY-r.top; draw(); }); ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ if(!dragging) return; dragging=false; if(currentDraw){ const arr = getRectsForFrame(currentIdx); // ignorer les clics trop petits if(Math.abs(currentDraw.x2-currentDraw.x1) > 3 && Math.abs(currentDraw.y2-currentDraw.y1) > 3){ arr.push({...currentDraw}); } currentDraw=null; draw(); } })); btnClear.onclick=()=>{ const arr = getRectsForFrame(currentIdx); if(arr.length){ arr.pop(); draw(); } }; btnEdit.onclick =()=> setMode('edit'); btnBack.onclick =()=> setMode('view'); // Palette palette.querySelectorAll('.swatch').forEach(el=>{ if(el.dataset.c===color) el.classList.add('sel'); el.onclick=()=>{ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel')); el.classList.add('sel'); color=el.dataset.c; if(currentDraw){ currentDraw.color=color; draw(); } }; }); // === Timeline (identique sauf interactions internes) === async function loadTimelineUrls(){ timelineUrls = []; const stem = vidStem, b = bustToken; for(let idx=0; idxa-b); for(const i of idxs){ addThumb(i,'append'); } setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0); return; } if(portionStart!=null && portionEnd!=null){ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd); for(let i=s;i{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0); return; } await loadWindow(centerIdx ?? currentIdx); loadingInd.style.display='none'; } async function loadWindow(centerIdx){ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays(); const rngStart = (viewRangeStart ?? 0); const rngEnd = (viewRangeEnd ?? frames); const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1))); const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize))); const end = Math.min(rngEnd, start + chunkSize); for(let i=start;i{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePortionOverlays(); },0); } function addThumb(idx, place='append'){ if(thumbEls.has(idx)) return; const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx; if(maskedSet.has(idx)) wrap.classList.add('hasmask'); const img=new Image(); img.title='frame '+(idx+1); img.src=timelineUrls[idx]; img.onerror = () => { const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`; img.onerror = null; img.src = fallback; img.onload = () => { const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`; timelineUrls[idx] = nu; img.src = nu; img.onload = null; }; }; if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); } img.onclick=async ()=>{ currentIdx=idx; player.currentTime=idxToSec(currentIdx); draw(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead(); }; wrap.appendChild(img); const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`; wrap.appendChild(label); if(place==='append'){ tlBox.appendChild(wrap); } else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); } else{ tlBox.appendChild(wrap); } thumbEls.set(idx, wrap); } tlBox.addEventListener('scroll', ()=>{ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){ updatePlayhead(); updatePortionOverlays(); return; } const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth; if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize); for(let i=timelineEnd;i viewRangeStart){ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize); for(let i=newStart;i{ const start = parseInt(goFrame.value || '1',10) - 1; const end = parseInt(endPortion.value || '',10); if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; } if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return; portionStart = start; portionEnd = end; viewRangeStart = start; viewRangeEnd = end; player.pause(); isPaused = true; currentIdx = start; player.currentTime = idxToSec(start); await renderTimeline(currentIdx); resetFull.style.display = 'inline-block'; startLoop(); updatePortionOverlays(); }; function startLoop(){ if(loopInterval) clearInterval(loopInterval); if(portionEnd != null){ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100); } } resetFull.onclick = async ()=>{ portionStart = null; portionEnd = null; viewRangeStart = 0; viewRangeEnd = frames; goFrame.value = 1; endPortion.value = ''; player.pause(); isPaused = true; await renderTimeline(currentIdx); resetFull.style.display='none'; clearInterval(loopInterval); updatePortionOverlays(); }; // Drag IN/OUT (inchangé) function attachHandleDrag(handle, which){ let draggingH=false; function onMove(e){ if(!draggingH) return; const idx = nearestFrameIdxFromClientX(e.clientX); if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); } else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; } viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames); updatePortionOverlays(); } handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); }); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', ()=>{ draggingH=false; }); } ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out'); // Progress popup (inchangé) async function showProgress(vidStem){ popup.style.display = 'block'; const interval = setInterval(async () => { const r = await fetch('/progress/' + vidStem); const d = await r.json(); tlProgressFill.style.width = d.percent + '%'; popupProgressFill.style.width = d.percent + '%'; popupLogs.innerHTML = d.logs.map(x=>String(x)).join('
'); if(d.done){ clearInterval(interval); popup.style.display = 'none'; await renderTimeline(currentIdx); } }, 800); } // Meta & boot async function loadVideoAndMeta() { if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; } vidStem = fileStem(vidName); bustToken = Date.now(); const bust = Date.now(); srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust; player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust); player.load(); fitCanvas(); statusEl.textContent = 'Chargement vidéo…'; try{ const r=await fetch('/meta/'+encodeURIComponent(vidName)); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`; viewRangeStart = 0; viewRangeEnd = frames; await loadTimelineUrls(); await loadMasks(); currentIdx = 0; player.currentTime = 0; await renderTimeline(0); showProgress(vidStem); }else{ statusEl.textContent = 'Erreur meta'; } }catch(err){ statusEl.textContent = 'Erreur réseau meta'; } } player.addEventListener('loadedmetadata', async ()=>{ fitCanvas(); if(!frames || frames<=0){ 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{} } currentIdx=0; goFrame.value=1; draw(); }); window.addEventListener('resize', ()=>{ fitCanvas(); draw(); }); player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); }); player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); }); player.addEventListener('timeupdate', ()=>{ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s'; currentIdx=timeToIdx(player.currentTime); if(mode==='edit'){ draw(); } updateHUD(); updateSelectedThumb(); updatePlayhead(); if(followMode && !isPaused){ const now = Date.now(); if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); } } }); goFrame.addEventListener('change', async ()=>{ if(!vidName) return; const val=Math.max(1, parseInt(goFrame.value||'1',10)); player.pause(); isPaused = true; currentIdx=val-1; player.currentTime=idxToSec(currentIdx); await renderTimeline(currentIdx); draw(); await ensureThumbVisibleCentered(currentIdx); }); // Follow / Filter / Zoom / Goto btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); }; btnFilterMasked.onclick = async ()=>{ maskedOnlyMode = !maskedOnlyMode; btnFilterMasked.classList.toggle('toggled', maskedOnlyMode); tlBox.classList.toggle('filter-masked', maskedOnlyMode); await renderTimeline(currentIdx); await ensureThumbVisibleCentered(currentIdx); }; zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); }); async function gotoFrameNum(){ const v = parseInt(gotoInput.value||'',10); if(!Number.isFinite(v) || v<1 || v>frames) return; player.pause(); isPaused = true; currentIdx = v-1; player.currentTime = idxToSec(currentIdx); goFrame.value = v; await renderTimeline(currentIdx); draw(); await ensureThumbVisibleCentered(currentIdx); } gotoBtn.onclick = ()=>{ gotoFrameNum(); }; gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } }); // Drag & drop upload const uploadZone = document.getElementById('uploadForm'); uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; }); uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; }); uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.style.borderColor = 'transparent'; const file = e.dataTransfer.files[0]; if(file && file.type.startsWith('video/')){ const fd = new FormData(); fd.append('file', file); fetch('/upload?redirect=1', {method: 'POST', body: fd}).then(() => location.reload()); } }); // Export placeholder document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); }; // Fichiers & masques (chargement existants => rectsMap) async function loadFiles(){ const r=await fetch('/files'); const d=await r.json(); if(!d.items || !d.items.length){ fileList.innerHTML='
  • (aucune)
  • '; return; } fileList.innerHTML=''; d.items.forEach(name=>{ const li=document.createElement('li'); const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo'; delBtn.onclick=async()=>{ if(!confirm(`Supprimer "${name}" ?`)) return; await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'}); loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); } }; const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo'; li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li); }); } async function loadMasks(){ loadingInd.style.display='block'; const box=document.getElementById('maskList'); const r=await fetch('/mask/'+encodeURIComponent(vidName)); const d=await r.json(); masks=d.masks||[]; maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10))); // Reconstituer rectsMap depuis les masques existants rectsMap.clear(); const normW = canvas.clientWidth, normH = canvas.clientHeight; masks.forEach(m=>{ if(m.shape==='rect'){ const [x1,y1,x2,y2] = m.points; // normalisés const rx = {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color||'#10b981'}; const arr = getRectsForFrame(parseInt(m.frame_idx||0,10)); arr.push(rx); } }); maskedCount.textContent = `(${maskedSet.size} ⭐)`; if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; draw(); return; } box.innerHTML=''; const ul=document.createElement('ul'); ul.className='clean'; masks.forEach(m=>{ const li=document.createElement('li'); const fr=(parseInt(m.frame_idx||0,10)+1); const t=(m.time_s||0).toFixed(2); const col=m.color||'#10b981'; const label=m.note||(`frame ${fr}`); li.innerHTML = ` ${label.replace(//g,'>')} — #${fr} · t=${t}s`; const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque'; renameBtn.onclick=async()=>{ const nv=prompt('Nouveau nom du masque :', label); if(nv===null) return; const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})}); if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else { const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt); } }; const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque'; delMaskBtn.onclick=async()=>{ if(!confirm(`Supprimer masque "${label}" ?`)) return; const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})}); if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else { const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt); } }; li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li); }); box.appendChild(ul); loadingInd.style.display='none'; draw(); } // Save masks (tous les rectangles de la frame courante) btnSave.onclick = async ()=>{ const arr = getRectsForFrame(currentIdx); if(currentDraw){ // finaliser le tracé en cours if(Math.abs(currentDraw.x2-currentDraw.x1) > 3 && Math.abs(currentDraw.y2-currentDraw.y1) > 3){ arr.push({...currentDraw}); } currentDraw=null; } if(!arr.length){ alert('Aucun rectangle sur cette frame.'); return; } const defaultName = `frame ${currentIdx+1}`; const baseNote = (prompt('Nom de base (optionnel) :', defaultName) || defaultName).trim(); const normW = canvas.clientWidth, normH = canvas.clientHeight; // Sauvegarder séquentiellement pour éviter les erreurs réseau cumulées for(let i=0;i """ @app.get("/ui", response_class=HTMLResponse, tags=["meta"]) def ui(v: Optional[str] = "", msg: Optional[str] = ""): vid = v or "" try: msg = urllib.parse.unquote(msg or "") except Exception: pass html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg) return HTMLResponse(content=html)