# 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 import shutil import cv2 import json import time import urllib.parse import sys import threading # <<<< HUGINFACE PATCH: WARMUP IMPORTS START >>>> from typing import List from huggingface_hub import snapshot_download from copy import deepcopy # <<<< 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": [], "ok_count": 0, "done": False, "current": None, "total": 0, "idx": 0, "job_id": "default", # NOUVEAUX CHAMPS POUR RÉCAP "asked": [], # liste demandée au lancement "ok_repos": [], # liste des repos téléchargés OK "cache_repos": [], # liste des repos ignorés car déjà en cache "downloaded_repos": [],# liste des repos réellement téléchargés pendant ce run "failed_repos": [], # liste des repos en échec "started_at": 0.0, # timestamp début "finished_at": 0.0, # timestamp fin } warmup_lock = threading.Lock() warmup_stop = threading.Event() # --- Shim anti-avertissement Pylance (défini si absent) ---------------------- if "_hf_cache_dir" not in globals(): def _hf_cache_dir() -> str: return os.path.expanduser(os.getenv("HF_HOME", "/home/user/.cache/huggingface")) if "_is_repo_cached" not in globals(): 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 _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): # correction douce de typos connues fixups = {"stabilityai/sd-vae-ft-ms": "stabilityai/sd-vae-ft-mse"} cleaned = [fixups.get(str(x).strip(), str(x).strip()) for x in lst if str(x).strip()] return cleaned except Exception: pass return [] def _log_warmup(msg: str): # >>> ICONES LOGS (ajout) <<< if msg.startswith("[CACHE]"): msg = "⬛ " + msg # ignoré: déjà en cache elif msg.startswith("[START]"): msg = "⏳ " + msg # démarrage d'un dépôt elif msg.startswith("[DONE]"): msg = "✅ " + msg # téléchargement OK elif msg.startswith("[FAIL]"): msg = "❌ " + msg # échec elif msg.startswith("[STOP]"): msg = "⏹️ " + msg # arrêt demandé 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 _dir_size_bytes(path: str) -> int: try: total = 0 for root, _, files in os.walk(path): for f in files: try: total += os.path.getsize(os.path.join(root, f)) except Exception: pass return total except Exception: return 0 def _download_one(repo_id: str, tries: int = 3) -> str: """ Renvoie un statut précis : - "cache" : déjà présent localement, rien à faire - "ok" : téléchargé avec succès - "fail" : échec après toutes les tentatives - "stopped" : arrêt demandé pendant le run """ cache_home = os.path.expanduser(os.getenv("HF_HOME", "/home/user/.cache/huggingface")) local_dir = os.path.join(cache_home, "models", repo_id.replace("/", "__")) # 1) Déjà en cache ? try: if _is_repo_cached(repo_id): sz = _dir_size_bytes(local_dir) _log_warmup(f"[CACHE] {repo_id} • {local_dir} • {sz/1e6:.1f} MB (skip)") return "cache" except Exception: pass # 2) Tentatives de téléchargement for attempt in range(1, tries + 1): if warmup_stop.is_set(): _log_warmup(f"[STOP] Abandon demandé avant téléchargement de {repo_id}") return "stopped" t0 = time.time() _log_warmup(f"[START] {repo_id} • tentative {attempt}/{tries} • {local_dir}") try: snapshot_download( repo_id, local_dir=local_dir, local_dir_use_symlinks=False, resume_download=True, ) dt = time.time() - t0 sz = _dir_size_bytes(local_dir) _log_warmup(f"[DONE] {repo_id} • {dt:.1f}s • {sz/1e6:.1f} MB") return "ok" except Exception as e: dt = time.time() - t0 _log_warmup(f"[FAIL] {repo_id} • {dt:.1f}s • {type(e).__name__}: {e}") time.sleep(min(10, 2 * attempt)) return "fail" def _warmup_thread(models: List[str]): ok_count = 0 t_global = time.time() _log_warmup(f"[JOB] start • {len(models)} dépôts") for i, repo in enumerate(models): if warmup_stop.is_set(): _log_warmup("[STOP] Arrêt demandé — fin du job après ce point") break with warmup_lock: warmup_state["idx"] = i warmup_state["current"] = repo warmup_state["percent"] = int((i / max(1, len(models))) * 100) res = _download_one(repo) with warmup_lock: if res == "ok": ok_count += 1 warmup_state["ok_count"] = ok_count warmup_state.setdefault("downloaded_repos", []).append(repo) elif res == "cache": warmup_state.setdefault("cache_repos", []).append(repo) elif res == "fail": warmup_state.setdefault("failed_repos", []).append(repo) elif res == "stopped": _log_warmup("[STOP] Fin anticipée — demande reçue pendant le run") warmup_state["percent"] = int(((i + 1) / max(1, len(models))) * 100) break warmup_state["percent"] = int(((i + 1) / max(1, len(models))) * 100) if warmup_stop.is_set(): _log_warmup("[STOP] Fin anticipée — demande reçue pendant le run") break with warmup_lock: warmup_state["percent"] = 100 warmup_state["done"] = True warmup_state["running"] = False warmup_state["ok_count"] = ok_count warmup_state["finished_at"] = time.time() _log_warmup(f"[JOB] done • {ok_count}/{len(models)} • {time.time()-t_global:.1f}s") # <<<< 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", "/warmup/catalog"] } @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): # Sécuriser le nom de fichier (UploadFile.filename peut être None) fname = file.filename or "upload.mp4" base = _safe_name(fname) ext = (Path(base).suffix or ".mp4").lower() if ext not in {".mp4", ".mov", ".mkv", ".webm"}: raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm") 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") # Remove redundant import and ensure imports are at the top of the file @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). """ # Nettoie un éventuel stop précédent pour permettre un nouveau run warmup_stop.clear() # 1) Prendre la liste fournie si présente if payload and isinstance(payload, dict): models = [str(x).strip() for x in (payload.get("models") or []) if str(x).strip()] else: models = [] # 2) Sinon charger la liste par défaut (depuis WARMUP_MODELS) if not models: models = _default_model_list() # 3) Normaliser (fix typos) + dédoublonner fixups = {"stabilityai/sd-vae-ft-ms": "stabilityai/sd-vae-ft-mse"} models = [fixups.get(m, m) for m in models] seen = set() models = [m for m in models if not (m in seen or seen.add(m))] # 4) Encore vide ? -> 400 if not models: raise HTTPException(400, "Aucun modèle fourni (payload.models) et WARMUP_MODELS vide") # 5) Lancer le job with warmup_lock: if warmup_state.get("running"): return {"ok": False, "already_running": True, "status": deepcopy(warmup_state)} # RESET d’un nouveau job job_id = uuid.uuid4().hex[:8] warmup_state.update({ "job_id": job_id, "asked": models[:], "ok_repos": [], "cache_repos": [], "downloaded_repos": [], "failed_repos": [], "ok_count": 0, "logs": [], "idx": 0, "total": len(models), "percent": 0, "running": True, "done": False, "current": None, "started_at": time.time(), "finished_at": 0.0, }) 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() _log_warmup("[STOP] Demande d'arrêt reçue — arrêt après le dépôt en cours") 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", ] # Correction douce de typos connues (catalog) fixups = {"stabilityai/sd-vae-ft-ms": "stabilityai/sd-vae-ft-mse"} base = [fixups.get(m, m) for m in 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"""