|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
from typing import List |
|
from huggingface_hub import snapshot_download |
|
from copy import deepcopy |
|
|
|
|
|
import subprocess |
|
|
|
import shutil as _shutil |
|
|
|
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} |
|
|
|
|
|
app = FastAPI(title="Video Editor API", version="0.5.9") |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
@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) |
|
|
|
|
|
progress_data: Dict[str, Dict[str, Any]] = {} |
|
|
|
|
|
warmup_state: Dict[str, Any] = { |
|
"running": False, |
|
"percent": 0, |
|
"logs": [], |
|
"ok_count": 0, |
|
"done": False, |
|
"current": None, |
|
"total": 0, |
|
"idx": 0, |
|
"job_id": "default", |
|
|
|
"asked": [], |
|
"ok_repos": [], |
|
"cache_repos": [], |
|
"downloaded_repos": [], |
|
"failed_repos": [], |
|
"started_at": 0.0, |
|
"finished_at": 0.0, |
|
} |
|
|
|
warmup_lock = threading.Lock() |
|
warmup_stop = threading.Event() |
|
|
|
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): |
|
|
|
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): |
|
|
|
if msg.startswith("[CACHE]"): |
|
msg = "⬛ " + msg |
|
elif msg.startswith("[START]"): |
|
msg = "⏳ " + msg |
|
elif msg.startswith("[DONE]"): |
|
msg = "✅ " + msg |
|
elif msg.startswith("[FAIL]"): |
|
msg = "❌ " + msg |
|
elif msg.startswith("[STOP]"): |
|
msg = "⏹️ " + msg |
|
|
|
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("/", "__")) |
|
|
|
|
|
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 |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
@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): |
|
|
|
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") |
|
|
|
|
|
@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). |
|
""" |
|
|
|
warmup_stop.clear() |
|
|
|
|
|
if payload and isinstance(payload, dict): |
|
models = [str(x).strip() for x in (payload.get("models") or []) if str(x).strip()] |
|
else: |
|
models = [] |
|
|
|
|
|
if not models: |
|
models = _default_model_list() |
|
|
|
|
|
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))] |
|
|
|
|
|
if not models: |
|
raise HTTPException(400, "Aucun modèle fourni (payload.models) et WARMUP_MODELS vide") |
|
|
|
|
|
with warmup_lock: |
|
if warmup_state.get("running"): |
|
return {"ok": False, "already_running": True, "status": deepcopy(warmup_state)} |
|
|
|
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.).""" |
|
|
|
with warmup_lock: |
|
data = deepcopy(warmup_state) |
|
data["instance_id"] = INSTANCE_ID |
|
|
|
|
|
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} |
|
|
|
|
|
|
|
|
|
|
|
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", |
|
] |
|
|
|
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} |
|
|
|
|
|
|
|
@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, |
|
} |
|
|
|
|
|
|
|
|
|
@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} |
|
|
|
|
|
HTML_TEMPLATE = r""" |
|
<!doctype html> |
|
<html lang="fr"><meta charset="utf-8"> |
|
<title>Video Editor</title> |
|
<style> |
|
:root{--b:#e5e7eb;--muted:#64748b; --controlsH:44px; --active-bg:#dbeafe; --active-border:#2563eb;} |
|
*{box-sizing:border-box} |
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial;margin:16px;color:#111} |
|
h1{margin:0 0 8px 0} |
|
.topbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px} |
|
.card{border:1px solid var(--b);border-radius:12px;padding:10px;background:#fff} |
|
.muted{color:var(--muted);font-size:13px} |
|
.layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start} |
|
.viewer{max-width:1024px;margin:0 auto; position:relative} |
|
.player-wrap{position:relative; padding-bottom: var(--controlsH);} |
|
video{display:block;width:100%;height:auto;max-height:58vh;border-radius:10px;box-shadow:0 0 0 1px #ddd} |
|
#editCanvas{position:absolute;left:0;right:0;top:0;bottom:var(--controlsH);border-radius:10px;pointer-events:none} |
|
.timeline-container{margin-top:10px} |
|
.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%} |
|
.thumb{flex:0 0 auto;display:inline-block;position:relative;transition:transform 0.2s;text-align:center} |
|
.thumb:hover{transform:scale(1.05)} |
|
.thumb img{height:var(--thumbH,110px);display:block;border-radius:6px;cursor:pointer;border:2px solid transparent;object-fit:cover} |
|
.thumb img.sel{border-color:var(--active-border)} |
|
.thumb img.sel-strong{outline:3px solid var(--active-border);box-shadow:0 0 0 3px #fff,0 0 0 5px var(--active-border)} |
|
.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} |
|
.thumb-label{font-size:11px;color:var(--muted);margin-top:2px;display:block} |
|
.timeline.filter-masked .thumb:not(.hasmask){display:none} |
|
.tools .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center} |
|
.btn{padding:8px 12px;border-radius:8px;border:1px solid var(--b);background:#f8fafc;cursor:pointer;transition:background 0.2s, border 0.2s} |
|
.btn:hover{background:var(--active-bg);border-color:var(--active-border)} |
|
.btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)} |
|
.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} |
|
.swatch.sel{box-shadow:0 0 0 2px var(--active-border)} |
|
ul.clean{list-style:none;padding-left:0;margin:6px 0} |
|
ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px} |
|
.rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:var(--muted);transition:color 0.2s} |
|
.rename-btn:hover{color:#2563eb} |
|
.delete-btn{color:#ef4444;font-size:14px;cursor:pointer;transition:color 0.2s} |
|
.delete-btn:hover{color:#b91c1c} |
|
#loading-indicator{display:none;margin-top:6px;color:#f59e0b} |
|
.portion-row{display:flex;gap:6px;align-items:center;margin-top:8px} |
|
.portion-input{width:70px} |
|
#tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px} |
|
#tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px} |
|
#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} |
|
#popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0} |
|
#popup-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px} |
|
#popup-logs {max-height:200px;overflow:auto;font-size:12px;color:#6b7280} |
|
#hud{position:absolute;right:10px;top:10px;background:rgba(17,24,39,.6);color:#fff;font-size:12px;padding:4px 8px;border-radius:6px} |
|
#toast{position:fixed;right:12px;bottom:12px;display:flex;flex-direction:column;gap:8px;z-index:2000} |
|
.toast-item{background:#111827;color:#fff;padding:8px 12px;border-radius:8px;box-shadow:0 6px 16px rgba(0,0,0,.25);opacity:.95} |
|
#portionBand{position:absolute;top:0;height:calc(var(--thumbH,110px) + 24px);background:rgba(37,99,235,.12);pointer-events:none;display:none} |
|
#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} |
|
.playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--active-border);opacity:.9;pointer-events:none;display:block} |
|
.repo-list{max-height:320px; overflow:auto; border:1px solid var(--b); border-radius:8px; padding:6px} |
|
.repo-item{display:flex; align-items:center; gap:8px; padding:6px; border-bottom:1px solid #f3f4f6} |
|
.repo-item:last-child{border-bottom:none} |
|
.repo-id{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px} |
|
/* Popups déplaçables : poignée = <h3> */ |
|
#warmupPopup h3, #warmupSelectPopup h3 { cursor: move; user-select: none; } |
|
|
|
</style> |
|
<h1>🎬 Video Editor</h1> |
|
<div class="topbar card"> |
|
<form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm"> |
|
<strong>Charger une vidéo :</strong> |
|
<input type="file" name="file" accept="video/*" required> |
|
<button class="btn" type="submit">Uploader</button> |
|
</form> |
|
<span class="muted" id="msg">__MSG__</span> |
|
|
|
|
|
|
|
|
|
|
|
|
|
<span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span> |
|
|
|
<!-- >>> A2B2_BEGIN warmup_topbar --> |
|
<div style="display:flex;gap:8px;align-items:center"> |
|
<button id="warmupStartBtn" class="btn" title="Télécharger les modèles (séquentiel)">🔥 Télécharger modèles</button> |
|
<button id="warmupStopBtn" class="btn" title="Arrêter le téléchargement en cours" style="display:none">⏹️ Stop téléchargement</button> |
|
<button id="warmupSelectBtn" class="btn" title="Sélectionner les repos à télécharger">🧩 Choisir modèles</button> |
|
<button id="warmupLogsBtn" class="btn" title="Voir les logs du téléchargement">📜 Logs téléchargement</button> |
|
<span id="warmupStatus" class="muted"></span> |
|
|
|
</div> |
|
<!-- >>> A2B2_END warmup_topbar --> |
|
|
|
|
|
</div> |
|
<div class="layout"> |
|
<div> |
|
<div class="viewer card" id="viewerCard"> |
|
<div class="player-wrap" id="playerWrap"> |
|
<video id="player" controls playsinline poster="/poster/__VID__"> |
|
<source id="vidsrc" src="/data/__VID__" type="video/mp4"> |
|
</video> |
|
<canvas id="editCanvas"></canvas> |
|
<div id="hud"></div> |
|
</div> |
|
<div class="muted" style="margin-top:6px"> |
|
<label>Frame # <input id="goFrame" type="number" min="1" value="1" style="width:90px"> </label> |
|
<label>à <input id="endPortion" class="portion-input" type="number" min="1" placeholder="Optionnel pour portion"></label> |
|
<button id="isolerBoucle" class="btn">Isoler & Boucle</button> |
|
<button id="resetFull" class="btn" style="display:none">Retour full</button> |
|
<span id="posInfo" style="margin-left:10px"></span> |
|
<span id="status" style="margin-left:10px;color:#2563eb"></span> |
|
</div> |
|
</div> |
|
<div class="card timeline-container"> |
|
<h4 style="margin:2px 0 8px 0">Timeline</h4> |
|
<div class="row" id="tlControls" style="margin:4px 0 8px 0;gap:8px;align-items:center"> |
|
<button id="btnFollow" class="btn" title="Centrer pendant la lecture (OFF par défaut)">🔭 Suivre</button> |
|
<button id="btnFilterMasked" class="btn" title="Afficher uniquement les frames avec masque">⭐ Masquées</button> |
|
<label class="muted">Zoom <input id="zoomSlider" type="range" min="80" max="180" value="110" step="10"></label> |
|
<label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label> |
|
<button id="gotoBtn" class="btn">Aller</button> |
|
<span class="muted" id="maskedCount"></span> |
|
</div> |
|
<div id="timeline" class="timeline"></div> |
|
<div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div> |
|
<div id="loading-indicator">Chargement des frames...</div> |
|
<div id="tl-progress-bar"><div id="tl-progress-fill"></div></div> |
|
</div> |
|
</div> |
|
<div class="card tools"> |
|
<div class="row"><span class="muted">Mode : <strong id="modeLabel">Lecture</strong></span></div> |
|
<div class="row" style="margin-top:6px"> |
|
<button id="btnEdit" class="btn" title="Passer en mode édition pour dessiner un masque">✏️ Éditer cette image</button> |
|
<button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button> |
|
<button id="btnSave" class="btn" style="display:none" title="Enregistrer tous les masques dessinés sur cette frame">💾 Enregistrer masque</button> |
|
<button id="btnClear" class="btn" style="display:none" title="Effacer le dernier rectangle de cette frame">🧽 Effacer sélection</button> |
|
</div> |
|
<div style="margin-top:10px"> |
|
<div class="muted">Couleur</div> |
|
<div class="row" id="palette" style="margin-top:6px"> |
|
<div class="swatch" data-c="#10b981" style="background:#10b981" title="Vert"></div> |
|
<div class="swatch" data-c="#2563eb" style="background:#2563eb" title="Bleu"></div> |
|
<div class="swatch" data-c="#ef4444" style="background:#ef4444" title="Rouge"></div> |
|
<div class="swatch" data-c="#f59e0b" style="background:#f59e0b" title="Orange"></div> |
|
<div class="swatch" data-c="#a21caf" style="background:#a21caf" title="Violet"></div> |
|
</div> |
|
</div> |
|
<div style="margin-top:12px"> |
|
<details open> |
|
<summary><strong>Masques</strong></summary> |
|
<div id="maskList" class="muted">—</div> |
|
<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> |
|
</details> |
|
<div style="margin-top:6px"> |
|
<strong>Vidéos disponibles</strong> |
|
<ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
<div id="popup"> |
|
<h3>Génération thumbs en cours</h3> |
|
<div id="popup-progress-bar"><div id="popup-progress-fill"></div></div> |
|
<div id="popup-logs"></div> |
|
</div> |
|
|
|
<!-- >>> A2B2_BEGIN warmup_popup --> |
|
<div id="warmupPopup" style="display:none; position:fixed; top:22%; 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:1001; min-width:360px"> |
|
<h3>Téléchargement des modèles (warm-up)</h3> |
|
|
|
<div class="muted" id="warmupPopupStatus">—</div> |
|
<div style="background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0"> |
|
<div id="warmup-progress-fill" style="background:#2563eb;height:100%;width:0;border-radius:4px"></div> |
|
</div> |
|
<div id="warmup-logs" style="max-height:220px; overflow:auto; font-size:12px; color:#6b7280; white-space:pre-wrap"></div> |
|
<div style="margin-top:10px; display:flex; gap:8px; justify-content:flex-end"> |
|
<button id="warmupCopyBtn" class="btn" title="Copier le contenu">Copier</button> |
|
<button id="warmupCloseBtn" class="btn">Fermer</button> |
|
</div> |
|
</div> |
|
<div id="warmupSelectPopup" style="display:none; position:fixed; top:24%; 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:1001; min-width:420px; max-width:720px; width:90%"> |
|
<h3>Sélection des modèles à préparer</h3> |
|
<div id="repoList" class="repo-list"></div> |
|
<div style="margin-top:12px; display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap"> |
|
<button id="selectCopyBtn" class="btn" title="Copier la liste visible">Copier</button> |
|
<button id="selectAllBtn" class="btn">Tout cocher</button> |
|
<button id="selectNoneBtn" class="btn">Tout décocher</button> |
|
<button id="selectCancelBtn" class="btn">Annuler</button> |
|
<button id="launchSelectedBtn" class="btn">Lancer le téléchargement</button> |
|
|
|
</div> |
|
</div> |
|
<!-- >>> A2B2_END warmup_popup --> |
|
<div id="toast"></div> |
|
<script> |
|
const serverVid = "__VID__"; |
|
const serverMsg = "__MSG__"; |
|
document.getElementById('msg').textContent = serverMsg; |
|
// Après affichage du message, on retire ?msg=... de l'URL pour éviter qu'il persiste au refresh. |
|
try { |
|
const u = new URL(location.href); |
|
if (u.searchParams.has('msg')) { |
|
u.searchParams.delete('msg'); |
|
const q = u.searchParams.toString(); |
|
history.replaceState({}, '', u.pathname + (q ? ('?' + q) : '')); |
|
} |
|
} catch (_) {} |
|
|
|
|
|
// Références DOM globales pour l’éditeur (nécessaires à tout le code en dessous) |
|
const statusEl = document.getElementById('status'); |
|
const player = document.getElementById('player'); |
|
const srcEl = document.getElementById('vidsrc'); |
|
const canvas = document.getElementById('editCanvas'); |
|
const ctx = canvas.getContext('2d'); |
|
const modeLabel = document.getElementById('modeLabel'); |
|
const btnEdit = document.getElementById('btnEdit'); |
|
const btnBack = document.getElementById('btnBack'); |
|
const btnSave = document.getElementById('btnSave'); |
|
const btnClear = document.getElementById('btnClear'); |
|
const posInfo = document.getElementById('posInfo'); |
|
const goFrame = document.getElementById('goFrame'); |
|
const palette = document.getElementById('palette'); |
|
const fileList = document.getElementById('fileList'); |
|
const tlBox = document.getElementById('timeline'); |
|
const tlNote = document.getElementById('tlNote'); |
|
const playerWrap = document.getElementById('playerWrap'); |
|
const loadingInd = document.getElementById('loading-indicator'); |
|
const isolerBoucle = document.getElementById('isolerBoucle'); |
|
const resetFull = document.getElementById('resetFull'); |
|
const endPortion = document.getElementById('endPortion'); |
|
const popup = document.getElementById('popup'); |
|
const popupLogs = document.getElementById('popup-logs'); |
|
const tlProgressFill = document.getElementById('tl-progress-fill'); |
|
const popupProgressFill = document.getElementById('popup-progress-fill'); |
|
const btnFollow = document.getElementById('btnFollow'); |
|
const btnFilterMasked = document.getElementById('btnFilterMasked'); |
|
const zoomSlider = document.getElementById('zoomSlider'); |
|
const maskedCount = document.getElementById('maskedCount'); |
|
const hud = document.getElementById('hud'); |
|
const toastWrap = document.getElementById('toast'); |
|
const gotoInput = document.getElementById('gotoInput'); |
|
|
|
|
|
// >>> 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'); |
|
const warmupCopyBtn = document.getElementById('warmupCopyBtn'); |
|
const warmupSelectCopyBtn = document.getElementById('warmupSelectCopyBtn'); |
|
|
|
// Mémorise la dernière instance vue pour détecter les bascules |
|
window._lastInstanceId = null; |
|
// Job courant (pour ignorer d’éventuelles réponses d’un ancien run) |
|
let currentJobId = null; |
|
|
|
|
|
|
|
const warmupCloseBtn = document.getElementById('warmupCloseBtn'); |
|
|
|
// Helpers popup |
|
function buildFinalSummary(s){ |
|
const asked = Array.isArray(s.asked) ? s.asked : []; |
|
const cacheL = Array.isArray(s.cache_repos) ? s.cache_repos : []; |
|
const dlL = Array.isArray(s.downloaded_repos) ? s.downloaded_repos : []; |
|
const failL = Array.isArray(s.failed_repos) ? s.failed_repos : []; |
|
|
|
const lines = []; |
|
lines.push(`Terminé — demandés: ${asked.length} • ⬛ cache: ${cacheL.length} • ✅ téléchargés: ${dlL.length} • ❌ échecs: ${failL.length}`); |
|
lines.push(""); |
|
|
|
lines.push("Demandés : " + asked.length); |
|
if (asked.length) lines.push(asked.map(m=>' • '+m).join('\n')); |
|
|
|
lines.push(""); |
|
lines.push(`⬛ Ignorés (déjà en cache) : ${cacheL.length}`); |
|
if (cacheL.length) lines.push(cacheL.map(m=>' • '+m).join('\n')); |
|
|
|
lines.push(""); |
|
lines.push(`✅ Téléchargés : ${dlL.length}`); |
|
if (dlL.length) lines.push(dlL.map(m=>' • '+m).join('\n')); |
|
|
|
lines.push(""); |
|
lines.push(`❌ Échecs : ${failL.length}`); |
|
if (failL.length) lines.push(failL.map(m=>' • '+m).join('\n')); |
|
|
|
return lines.join('\n'); |
|
} |
|
|
|
// >>> 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'; } |
|
let userClosedWarmupPopup = false; |
|
|
|
if (warmupCloseBtn) warmupCloseBtn.addEventListener('click', closeWarmupPopup); |
|
// -- DRAG & COPY (popups warmup) -- |
|
(function(){ |
|
const $popup = document.getElementById('warmupPopup'); |
|
const $sel = document.getElementById('warmupSelectPopup'); |
|
const $copy = document.getElementById('warmupCopyBtn'); |
|
const $copy2 = document.getElementById('selectCopyBtn'); |
|
|
|
function makeDraggable(box){ |
|
if (!box) return; |
|
let sx=0, sy=0, ox=0, oy=0, drag=false; |
|
box.addEventListener('mousedown', e=>{ |
|
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return; |
|
drag=true; box.style.transform='none'; |
|
ox = box.offsetLeft; oy = box.offsetTop; sx = e.clientX; sy = e.clientY; |
|
document.body.style.userSelect='none'; |
|
}); |
|
window.addEventListener('mousemove', e=>{ |
|
if(!drag) return; |
|
box.style.left = (ox + (e.clientX - sx)) + 'px'; |
|
box.style.top = (oy + (e.clientY - sy)) + 'px'; |
|
}); |
|
window.addEventListener('mouseup', ()=>{ drag=false; document.body.style.userSelect=''; }); |
|
} |
|
makeDraggable($popup); |
|
makeDraggable($sel); |
|
|
|
function copyText(txt){ |
|
if (navigator.clipboard && navigator.clipboard.writeText){ |
|
navigator.clipboard.writeText(txt||'').then(()=> (window.showToast?showToast('Copié ✅'):alert('Copié ✅')) ); |
|
} else { |
|
const ta=document.createElement('textarea'); ta.value=txt||''; document.body.appendChild(ta); ta.select(); |
|
try{ document.execCommand('copy'); (window.showToast?showToast('Copié ✅'):alert('Copié ✅')); }catch{} |
|
ta.remove(); |
|
} |
|
} |
|
if ($copy){ |
|
$copy.addEventListener('click', ()=>{ |
|
const s = document.getElementById('warmupPopupStatus'); |
|
const l = document.getElementById('warmup-logs'); |
|
copyText((s?('[Status] '+s.textContent+'\n\n'):'') + (l?l.textContent:'')); |
|
}); |
|
} |
|
if ($copy2){ |
|
$copy2.addEventListener('click', ()=>{ |
|
const list = document.getElementById('repoList'); |
|
copyText(list ? list.innerText : ''); |
|
}); |
|
} |
|
})(); |
|
|
|
// 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 (multi-répliques) |
|
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 (warmupStopBtn) warmupStopBtn.style.display = running ? 'inline-block' : 'none'; |
|
|
|
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 if (s.done) { |
|
const cacheN = Array.isArray(s.cache_repos) ? s.cache_repos.length : 0; |
|
const dlN = Array.isArray(s.downloaded_repos) ? s.downloaded_repos.length : 0; |
|
const failN = Array.isArray(s.failed_repos) ? s.failed_repos.length : Math.max(0, tot - (s.ok_count||0)); |
|
warmupStatusEl.textContent = `✅ Terminé — ⬛ ${cacheN} • ✅ ${dlN} • ❌ ${failN} / ${tot} [inst:${instanceId}]`; |
|
if (warmupPopupStatus) warmupPopupStatus.textContent = 'Terminé'; |
|
if (warmupLogs) warmupLogs.textContent = buildFinalSummary(s); |
|
} else { |
|
const nCache = Number.isFinite(s.audit_count) |
|
? s.audit_count |
|
: (Array.isArray(s.audit_cached) ? s.audit_cached.length : 0); |
|
warmupStatusEl.textContent = nCache > 0 |
|
? `✅ Prêt — cache local: ${nCache} [inst:${instanceId}]` |
|
: `Prêt (aucun run)`; |
|
} |
|
} |
|
|
|
if (warmupProgressFill) warmupProgressFill.style.width = pct + '%'; |
|
if (warmupPopupStatus) warmupPopupStatus.textContent = running ? 'Téléchargement en cours…' : 'Terminé'; |
|
|
|
// Logs live dans la popup pendant l’exécution |
|
if (running && warmupLogs) { |
|
const logsTxt = Array.isArray(s.logs) ? s.logs.join('\n') : ''; |
|
const asked = Array.isArray(s.asked) |
|
? s.asked |
|
: (Array.isArray(window.lastRequestedModels) ? window.lastRequestedModels : []); |
|
const preface = (warmupPreface && warmupPreface.trim()) |
|
? warmupPreface |
|
: ('Demandés (' + asked.length + '):\n' + asked.map(m=>' • '+m).join('\n')); |
|
warmupLogs.textContent = preface + '\n\n' + (logsTxt || '— en cours…'); |
|
warmupLogs.scrollTop = warmupLogs.scrollHeight; |
|
} |
|
|
|
// Gestion du polling + ouverture/fermeture popup |
|
if (running) { |
|
if (!userClosedWarmupPopup) openWarmupPopup(); |
|
if (!warmupTimer) warmupTimer = setInterval(refreshWarmupUI, 1000); |
|
} else { |
|
if (warmupTimer) { clearInterval(warmupTimer); warmupTimer = null; } |
|
userClosedWarmupPopup = false; |
|
} |
|
|
|
}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 |
|
// >>> PRÉAMBULE EXPLICITE (ajout) <<< |
|
try { |
|
const ra2 = await fetch('/warmup/audit'); |
|
const audit2 = ra2.ok ? await ra2.json() : null; |
|
const cached2 = (audit2 && Array.isArray(audit2.cached)) ? audit2.cached : []; |
|
const ignored = models.filter(m => cached2.includes(m)); |
|
warmupPreface = |
|
'Demandés (' + models.length + '):\n' + |
|
models.map(m => ' • ' + m).join('\n') + '\n\n' + |
|
'⬛ Ignorés (déjà en cache) (' + ignored.length + '):\n' + |
|
ignored.map(m => ' • ' + m).join('\n') + '\n\n' + |
|
'📦 Déjà en cache global (' + cached2.length + '):\n' + |
|
cached2.map(m => ' • ' + m).join('\n'); |
|
} catch(_) { /* no-op */ } |
|
// >>> FIN AJOUT <<< |
|
|
|
userClosedWarmupPopup = false; |
|
openWarmupPopup(); |
|
if (warmupPopupStatus) warmupPopupStatus.textContent = 'Téléchargement 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; |
|
} |
|
|
|
// Lecture unique du payload (évite les doublons) |
|
const payload = await r.json().catch(()=>null); |
|
if (payload && payload.already_running) { |
|
showToast("Un warm-up est déjà en cours — j'affiche l'état."); |
|
} |
|
// Mémoriser un éventuel job_id renvoyé |
|
if (payload && (payload.job_id || payload.job)) { |
|
currentJobId = payload.job_id || payload.job; |
|
} |
|
|
|
// Rafraîchir l’UI + lancer le polling local |
|
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 = '<div class="muted">Chargement…</div>'; |
|
try{ |
|
const r = await fetch('/warmup/catalog'); |
|
if(!r.ok){ repoListEl.innerHTML = '<div class="muted">Erreur de chargement.</div>'; return; } |
|
const d = await r.json(); |
|
const items = Array.isArray(d.models) ? d.models : []; |
|
if(!items.length){ repoListEl.innerHTML = '<div class="muted">Aucun modèle proposé.</div>'; 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 = '<div class="muted">Erreur réseau.</div>'; |
|
} |
|
} |
|
|
|
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); |
|
// Réinitialiser l’état d’affichage pour ce nouveau run |
|
warmupPreface = ''; |
|
window.lastRequestedModels = picks.slice(); |
|
|
|
// Construire immédiatement une préface exacte (Demandés + Ignorés + Cache global) |
|
try { |
|
const ra = await fetch('/warmup/audit'); |
|
const audit = ra.ok ? await ra.json() : null; |
|
const cached = (audit && Array.isArray(audit.cached)) ? audit.cached : []; |
|
const ignored = picks.filter(m => cached.includes(m)); |
|
warmupPreface = |
|
'Demandés (' + picks.length + '):\n' + |
|
picks.map(m => ' • ' + m).join('\n') + '\n\n' + |
|
'⬛ Ignorés (déjà en cache) (' + ignored.length + '):\n' + |
|
(ignored.length ? ignored.map(m => ' • ' + m).join('\n') : '') + '\n\n' + |
|
'📦 Déjà en cache global (' + cached.length + '):\n' + |
|
(cached.length ? cached.map(m => ' • ' + m).join('\n') : ''); |
|
} catch(_) { /* no-op */ } |
|
|
|
if(!picks.length){ alert('Sélectionne au moins un modèle.'); return; } |
|
window.lastRequestedModels = picks.slice(); |
|
userClosedWarmupPopup = false; |
|
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 metaPromise = null; // évite les doubles requêtes /meta |
|
let currentIdx = 0; |
|
let mode = 'view'; |
|
|
|
// ==== Multi-rects ==== |
|
let currentDraw = null; // rect en cours de tracé |
|
let rectsMap = new Map(); // frame_idx -> Array<rect> |
|
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; idx<frames; idx++){ |
|
timelineUrls[idx] = `/thumbs/f_${stem}_${idx}.jpg?b=${b}`; |
|
} |
|
tlProgressFill.style.width='0%'; |
|
} |
|
async function renderTimeline(centerIdx){ |
|
if(!vidName) return; |
|
loadingInd.style.display='block'; |
|
if(timelineUrls.length===0) await loadTimelineUrls(); |
|
tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays(); |
|
|
|
if(maskedOnlyMode){ |
|
const idxs = Array.from(maskedSet).sort((a,b)=>a-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<e;i++){ addThumb(i,'append'); } |
|
setTimeout(async ()=>{ 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<end;i++){ addThumb(i,'append'); } |
|
timelineStart = start; timelineEnd = end; |
|
setTimeout(async ()=>{ |
|
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]; |
|
// Fallback solide : on affiche /frame_idx tout de suite, |
|
// puis on ne bascule vers /thumbs que si elle existe (sonde en tâche de fond). |
|
img.addEventListener('error', ()=>{ |
|
const fallback = `/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`; |
|
img.onerror = null; // éviter les boucles d'erreur |
|
img.src = fallback; // montrer la frame générée à la volée |
|
|
|
const trySwap = () => { |
|
const nu = `/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`; |
|
const probe = new Image(); |
|
probe.onload = () => { timelineUrls[idx] = nu; img.src = nu; }; |
|
probe.onerror = () => { setTimeout(trySwap, 1500); }; // réessaie plus tard |
|
probe.src = nu; |
|
}; |
|
setTimeout(trySwap, 1500); |
|
}, { once:true }); |
|
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<newEnd;i++){ addThumb(i,'append'); } |
|
timelineEnd = newEnd; |
|
} |
|
if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){ |
|
const newStart = Math.max(viewRangeStart, timelineStart - chunkSize); |
|
for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); } |
|
tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8); |
|
timelineStart = newStart; |
|
} |
|
updatePlayhead(); updatePortionOverlays(); |
|
}); |
|
|
|
// Isoler & Boucle (inchangé) |
|
isolerBoucle.onclick = async ()=>{ |
|
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('<br>'); |
|
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{ |
|
metaPromise = fetch('/meta/'+encodeURIComponent(vidName)); |
|
const r = await metaPromise; |
|
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{ |
|
if (metaPromise) { |
|
const r = await metaPromise; |
|
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='<li>(aucune)</li>'; 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 = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${col};margin-right:6px;vertical-align:middle"></span> <strong>${label.replace(/</g,'<').replace(/>/g,'>')}</strong> — #${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<arr.length;i++){ |
|
const r = arr[i]; |
|
const x=Math.min(r.x1,r.x2)/normW; |
|
const y=Math.min(r.y1,r.y2)/normH; |
|
const w=Math.abs(r.x2-r.x1)/normW; |
|
const h=Math.abs(r.y2-r.y1)/normH; |
|
const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:r.color||color,note:`${baseNote} #${i+1}`}; |
|
addPending(payload); |
|
try{ |
|
const res = await fetch('/mask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); |
|
if(!res.ok){ |
|
const txt = await res.text(); |
|
alert('Échec enregistrement masque: ' + res.status + ' ' + txt); |
|
break; |
|
} |
|
}catch(e){ |
|
alert('Erreur réseau lors de l’enregistrement.'); |
|
break; |
|
} |
|
} |
|
// Nettoyage des pendings naïf (optionnel) |
|
savePendingList([]); |
|
await loadMasks(); await renderTimeline(currentIdx); |
|
showToast('Masques enregistrés ✅'); |
|
}; |
|
|
|
// Boot |
|
async function boot(){ |
|
const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ } |
|
await loadFiles(); |
|
if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); } |
|
else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } } |
|
} |
|
boot(); |
|
|
|
// Masquer les contrôles natifs en mode édition |
|
const style = document.createElement('style'); |
|
style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`; |
|
document.head.appendChild(style); |
|
</script> |
|
</html> |
|
""" |
|
|
|
@app.get("/ui", response_class=HTMLResponse, tags=["meta"]) |
|
def ui(v: Optional[str] = "", msg: Optional[str] = ""): |
|
vid = v or "" |
|
try: |
|
safe_msg: str = urllib.parse.unquote(msg or "") |
|
except Exception: |
|
safe_msg = "" |
|
html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", safe_msg) |
|
return HTMLResponse(content=html) |
|
|