Update app.py
Browse files
app.py
CHANGED
@@ -1,8 +1,7 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
#
|
4 |
-
#
|
5 |
-
# - Bouton "Enregistrer masque" : erreurs visibles (alert) si l’API ne répond pas OK
|
6 |
from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
|
7 |
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
|
8 |
from fastapi.staticfiles import StaticFiles
|
@@ -48,7 +47,7 @@ def get_backend_base() -> str:
|
|
48 |
print("[BOOT] Video Editor API starting…")
|
49 |
print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
|
50 |
print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
|
51 |
-
app = FastAPI(title="Video Editor API", version="0.
|
52 |
DATA_DIR = Path("/app/data")
|
53 |
THUMB_DIR = DATA_DIR / "_thumbs"
|
54 |
MASK_DIR = DATA_DIR / "_masks"
|
@@ -258,121 +257,123 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
|
|
258 |
except Exception as e:
|
259 |
progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
|
260 |
progress_data[vid_stem]['done'] = True
|
261 |
-
# ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
262 |
def is_gpu():
|
263 |
import torch
|
264 |
return torch.cuda.is_available()
|
265 |
-
# ---
|
266 |
-
import huggingface_hub as hf
|
267 |
-
HF_HOME = Path(os.getenv("HF_HOME", "/home/user/.cache/huggingface")).resolve()
|
268 |
-
HF_HOME.mkdir(parents=True, exist_ok=True)
|
269 |
-
MODELS_TO_PREP = [
|
270 |
-
"facebook/sam2-hiera-large",
|
271 |
-
"ByteDance/Sa2VA-4B",
|
272 |
-
"lixiaowen/diffuEraser",
|
273 |
-
"runwayml/stable-diffusion-v1-5",
|
274 |
-
"wangfuyun/PCM_Weights",
|
275 |
-
"stabilityai/sd-vae-ft-mse"
|
276 |
-
]
|
277 |
-
WARMUP_STATUS = {
|
278 |
-
"running": False,
|
279 |
-
"done": False,
|
280 |
-
"step": None,
|
281 |
-
"ok": [],
|
282 |
-
"errors": [],
|
283 |
-
"progress": 0,
|
284 |
-
}
|
285 |
-
def _snapshot_once(repo_id):
|
286 |
-
target = HF_HOME / repo_id.split("/")[-1]
|
287 |
-
try:
|
288 |
-
print(f"[WARMUP] Prepare {repo_id} → {target}")
|
289 |
-
hf.snapshot_download(repo_id=repo_id, local_dir=str(target), token=os.getenv("HF_TOKEN"))
|
290 |
-
return True, None
|
291 |
-
except Exception as e:
|
292 |
-
return False, str(e)
|
293 |
-
# ProPainter
|
294 |
-
PROP = Path("/app/propainter"); PROP.mkdir(exist_ok=True)
|
295 |
-
PROP_FILES = [
|
296 |
-
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth",
|
297 |
-
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth",
|
298 |
-
"https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth",
|
299 |
-
]
|
300 |
-
def _ensure_propainter():
|
301 |
-
for url in PROP_FILES:
|
302 |
-
fname = url.split("/")[-1]
|
303 |
-
if not (PROP / fname).exists():
|
304 |
-
print(f"[WARMUP] wget {fname}")
|
305 |
-
subprocess.run(["wget", "-q", url, "-P", str(PROP)], check=False)
|
306 |
-
def _run_warmup():
|
307 |
-
WARMUP_STATUS.update({"running": True, "done": False, "step": None, "ok": [], "errors": [], "progress": 0})
|
308 |
-
total = len(MODELS_TO_PREP) + 1
|
309 |
-
step_idx = 0
|
310 |
-
for repo in MODELS_TO_PREP:
|
311 |
-
WARMUP_STATUS["step"] = repo
|
312 |
-
step_idx += 1
|
313 |
-
for attempt in range(1, 4):
|
314 |
-
ok, err = _snapshot_once(repo)
|
315 |
-
if ok:
|
316 |
-
WARMUP_STATUS["ok"].append(repo)
|
317 |
-
break
|
318 |
-
else:
|
319 |
-
WARMUP_STATUS["errors"].append({"repo": repo, "attempt": attempt, "error": err})
|
320 |
-
time.sleep(5)
|
321 |
-
WARMUP_STATUS["progress"] = int(step_idx * 100 / total)
|
322 |
-
WARMUP_STATUS["step"] = "ProPainter"
|
323 |
-
step_idx += 1
|
324 |
-
_ensure_propainter()
|
325 |
-
WARMUP_STATUS["progress"] = 100
|
326 |
-
WARMUP_STATUS.update({"running": False, "done": True})
|
327 |
-
# --- Nouveaux Endpoints pour IA stubs (lazy, no load yet) ---
|
328 |
@app.post("/mask/ai")
|
329 |
async def mask_ai(payload: Dict[str, Any] = Body(...)):
|
330 |
-
if not is_gpu(): raise HTTPException(503, "Switch
|
331 |
-
# TODO:
|
332 |
return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
|
333 |
@app.post("/inpaint")
|
334 |
async def inpaint(payload: Dict[str, Any] = Body(...)):
|
335 |
-
if not is_gpu(): raise HTTPException(503, "Switch
|
336 |
-
# TODO:
|
337 |
return {"ok": True, "preview": "/data/preview.mp4"}
|
338 |
@app.get("/estimate")
|
339 |
def estimate(vid: str, masks_count: int):
|
340 |
-
# TODO:
|
341 |
-
time_min
|
342 |
-
vram_gb = 4 if is_gpu() else 0
|
343 |
-
return {"time_min": time_min, "vram_gb": vram_gb}
|
344 |
@app.get("/progress_ia")
|
345 |
def progress_ia(vid: str):
|
346 |
-
# TODO:
|
347 |
return {"percent": 0, "log": "En cours..."}
|
348 |
-
# --- Routes
|
349 |
-
@app.post("/
|
350 |
-
def
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
360 |
def root():
|
361 |
return {
|
362 |
"ok": True,
|
363 |
-
"routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"
|
364 |
}
|
365 |
-
@app.get("/health"
|
366 |
def health():
|
367 |
return {"status": "ok"}
|
368 |
-
@app.get("/_env"
|
369 |
def env_info():
|
370 |
return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
|
371 |
-
@app.get("/files"
|
372 |
def files():
|
373 |
items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
|
374 |
return {"count": len(items), "items": items}
|
375 |
-
@app.get("/meta/{vid}"
|
376 |
def video_meta(vid: str):
|
377 |
v = DATA_DIR / vid
|
378 |
if not v.exists():
|
@@ -381,7 +382,7 @@ def video_meta(vid: str):
|
|
381 |
if not m:
|
382 |
raise HTTPException(500, "Métadonnées indisponibles")
|
383 |
return m
|
384 |
-
@app.post("/upload"
|
385 |
async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
|
386 |
ext = (Path(file.filename).suffix or ".mp4").lower()
|
387 |
if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
|
@@ -401,10 +402,10 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
|
|
401 |
msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
|
402 |
return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
|
403 |
return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
|
404 |
-
@app.get("/progress/{vid_stem}"
|
405 |
def progress(vid_stem: str):
|
406 |
return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
|
407 |
-
@app.delete("/delete/{vid}"
|
408 |
def delete_video(vid: str):
|
409 |
v = DATA_DIR / vid
|
410 |
if not v.exists():
|
@@ -416,7 +417,7 @@ def delete_video(vid: str):
|
|
416 |
v.unlink(missing_ok=True)
|
417 |
print(f"[DELETE] {vid}", file=sys.stdout)
|
418 |
return {"deleted": vid}
|
419 |
-
@app.get("/frame_idx"
|
420 |
def frame_idx(vid: str, idx: int):
|
421 |
v = DATA_DIR / vid
|
422 |
if not v.exists():
|
@@ -431,7 +432,7 @@ def frame_idx(vid: str, idx: int):
|
|
431 |
except Exception as e:
|
432 |
print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
|
433 |
raise HTTPException(500, "Frame error")
|
434 |
-
@app.get("/poster/{vid}"
|
435 |
def poster(vid: str):
|
436 |
v = DATA_DIR / vid
|
437 |
if not v.exists():
|
@@ -440,7 +441,7 @@ def poster(vid: str):
|
|
440 |
if p.exists():
|
441 |
return FileResponse(str(p), media_type="image/jpeg")
|
442 |
raise HTTPException(404, "Poster introuvable")
|
443 |
-
@app.get("/window/{vid}"
|
444 |
def window(vid: str, center: int = 0, count: int = 21):
|
445 |
v = DATA_DIR / vid
|
446 |
if not v.exists():
|
@@ -467,57 +468,7 @@ def window(vid: str, center: int = 0, count: int = 21):
|
|
467 |
items.append({"i": i, "idx": idx, "url": url})
|
468 |
print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
|
469 |
return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
|
470 |
-
#
|
471 |
-
@app.post("/mask", tags=["mask"])
|
472 |
-
async def save_mask(payload: Dict[str, Any] = Body(...)):
|
473 |
-
vid = payload.get("vid")
|
474 |
-
if not vid:
|
475 |
-
raise HTTPException(400, "vid manquant")
|
476 |
-
pts = payload.get("points") or []
|
477 |
-
if len(pts) != 4:
|
478 |
-
raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
|
479 |
-
data = _load_masks(vid)
|
480 |
-
m = {
|
481 |
-
"id": uuid.uuid4().hex[:10],
|
482 |
-
"time_s": float(payload.get("time_s") or 0.0),
|
483 |
-
"frame_idx": int(payload.get("frame_idx") or 0),
|
484 |
-
"shape": "rect",
|
485 |
-
"points": [float(x) for x in pts],
|
486 |
-
"color": payload.get("color") or "#10b981",
|
487 |
-
"note": payload.get("note") or ""
|
488 |
-
}
|
489 |
-
data.setdefault("masks", []).append(m)
|
490 |
-
_save_masks(vid, data)
|
491 |
-
print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
|
492 |
-
return {"saved": True, "mask": m}
|
493 |
-
@app.get("/mask/{vid}", tags=["mask"])
|
494 |
-
def list_masks(vid: str):
|
495 |
-
return _load_masks(vid)
|
496 |
-
@app.post("/mask/rename", tags=["mask"])
|
497 |
-
async def rename_mask(payload: Dict[str, Any] = Body(...)):
|
498 |
-
vid = payload.get("vid")
|
499 |
-
mid = payload.get("id")
|
500 |
-
new_note = (payload.get("note") or "").strip()
|
501 |
-
if not vid or not mid:
|
502 |
-
raise HTTPException(400, "vid et id requis")
|
503 |
-
data = _load_masks(vid)
|
504 |
-
for m in data.get("masks", []):
|
505 |
-
if m.get("id") == mid:
|
506 |
-
m["note"] = new_note
|
507 |
-
_save_masks(vid, data)
|
508 |
-
return {"ok": True}
|
509 |
-
raise HTTPException(404, "Masque introuvable")
|
510 |
-
@app.post("/mask/delete", tags=["mask"])
|
511 |
-
async def delete_mask(payload: Dict[str, Any] = Body(...)):
|
512 |
-
vid = payload.get("vid")
|
513 |
-
mid = payload.get("id")
|
514 |
-
if not vid or not mid:
|
515 |
-
raise HTTPException(400, "vid et id requis")
|
516 |
-
data = _load_masks(vid)
|
517 |
-
data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
|
518 |
-
_save_masks(vid, data)
|
519 |
-
return {"ok": True}
|
520 |
-
# ---------- UI ----------
|
521 |
HTML_TEMPLATE = r"""
|
522 |
<!doctype html>
|
523 |
<html lang="fr"><meta charset="utf-8">
|
@@ -585,7 +536,6 @@ HTML_TEMPLATE = r"""
|
|
585 |
<input type="file" name="file" accept="video/*" required>
|
586 |
<button class="btn" type="submit">Uploader</button>
|
587 |
</form>
|
588 |
-
<button class="btn" id="btnWarmup" type="button">Warm-up (préparer les modèles)</button>
|
589 |
<span class="muted" id="msg">__MSG__</span>
|
590 |
<span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
|
591 |
</div>
|
@@ -617,15 +567,15 @@ HTML_TEMPLATE = r"""
|
|
617 |
<label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
|
618 |
<button id="gotoBtn" class="btn">Aller</button>
|
619 |
<span class="muted" id="maskedCount"></span>
|
620 |
-
<!--
|
621 |
-
<button id="btnUndo"
|
622 |
-
<button id="btnRedo"
|
623 |
</div>
|
624 |
<div id="timeline" class="timeline"></div>
|
625 |
<div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
|
626 |
<div id="loading-indicator">Chargement des frames...</div>
|
627 |
<div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
|
628 |
-
<!--
|
629 |
<div id="progressBar"><div id="progressFill"></div></div>
|
630 |
<div>Progression IA: <span id="progressLog">En attente...</span></div>
|
631 |
</div>
|
@@ -637,8 +587,8 @@ HTML_TEMPLATE = r"""
|
|
637 |
<button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
|
638 |
<button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
|
639 |
<button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
|
640 |
-
<!--
|
641 |
-
<button id="btnPreview"
|
642 |
</div>
|
643 |
<div style="margin-top:10px">
|
644 |
<div class="muted">Couleur</div>
|
@@ -669,7 +619,7 @@ HTML_TEMPLATE = r"""
|
|
669 |
<div id="popup-logs"></div>
|
670 |
</div>
|
671 |
<div id="toast"></div>
|
672 |
-
<!--
|
673 |
<div id="tutorial" onclick="this.classList.add('hidden')">Tutoriel (cliquer pour masquer)<br>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</div>
|
674 |
<script>
|
675 |
const serverVid = "__VID__";
|
@@ -709,13 +659,12 @@ const hud = document.getElementById('hud');
|
|
709 |
const toastWrap = document.getElementById('toast');
|
710 |
const gotoInput = document.getElementById('gotoInput');
|
711 |
const gotoBtn = document.getElementById('gotoBtn');
|
712 |
-
// Nouveaux éléments
|
713 |
const btnUndo = document.getElementById('btnUndo');
|
714 |
const btnRedo = document.getElementById('btnRedo');
|
715 |
const btnPreview = document.getElementById('btnPreview');
|
716 |
const progressFill = document.getElementById('progressFill');
|
717 |
const progressLog = document.getElementById('progressLog');
|
718 |
-
const btnWarmup = document.getElementById('btnWarmup');
|
719 |
// State
|
720 |
let vidName = serverVid || '';
|
721 |
function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
|
@@ -1061,7 +1010,7 @@ async function loadVideoAndMeta() {
|
|
1061 |
vidStem = fileStem(vidName); bustToken = Date.now();
|
1062 |
const bust = Date.now();
|
1063 |
srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
|
1064 |
-
player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust
|
1065 |
player.load();
|
1066 |
fitCanvas();
|
1067 |
statusEl.textContent = 'Chargement vidéo…';
|
@@ -1241,12 +1190,59 @@ btnSave.onclick = async ()=>{
|
|
1241 |
alert('Erreur réseau lors de l’enregistrement du masque.');
|
1242 |
}
|
1243 |
};
|
1244 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1245 |
async function boot(){
|
1246 |
-
const lst =
|
1247 |
await loadFiles();
|
1248 |
if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
|
1249 |
else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
|
|
|
|
|
|
|
|
|
|
|
1250 |
}
|
1251 |
boot();
|
1252 |
// Hide controls in edit-mode
|
|
|
1 |
+
...donne des parties à corriger, mais moi je veux le script complet et corrigé, sans rien casser.
|
2 |
+
|
3 |
+
# app.py — Video Editor API (v0.8.0)
|
4 |
+
# v0.8.0: Chargement modèles Hub, stubs IA, + Améliorations : multi-masques, estimation /estimate, progression /progress_ia
|
|
|
5 |
from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
|
6 |
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
|
7 |
from fastapi.staticfiles import StaticFiles
|
|
|
47 |
print("[BOOT] Video Editor API starting…")
|
48 |
print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
|
49 |
print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
|
50 |
+
app = FastAPI(title="Video Editor API", version="0.8.0")
|
51 |
DATA_DIR = Path("/app/data")
|
52 |
THUMB_DIR = DATA_DIR / "_thumbs"
|
53 |
MASK_DIR = DATA_DIR / "_masks"
|
|
|
257 |
except Exception as e:
|
258 |
progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
|
259 |
progress_data[vid_stem]['done'] = True
|
260 |
+
# --- Chargement Modèles au Boot ---
|
261 |
+
import huggingface_hub as hf
|
262 |
+
from joblib import Parallel, delayed
|
263 |
+
def load_model(repo_id):
|
264 |
+
path = Path(os.environ["HF_HOME"]) / repo_id.split("/")[-1]
|
265 |
+
if not path.exists() or not any(path.iterdir()):
|
266 |
+
print(f"[BOOT] Downloading {repo_id}...")
|
267 |
+
hf.snapshot_download(repo_id=repo_id, local_dir=str(path), token=os.getenv("HF_TOKEN"))
|
268 |
+
(path / "loaded.ok").touch()
|
269 |
+
# Symlink exemples
|
270 |
+
if "sam2" in repo_id: shutil.copytree(str(path), "/app/sam2", dirs_exist_ok=True)
|
271 |
+
models = [
|
272 |
+
"facebook/sam2-hiera-large", "ByteDance/Sa2VA-4B", "lixiaowen/diffuEraser",
|
273 |
+
"runwayml/stable-diffusion-v1-5", "wangfuyun/PCM_Weights", "stabilityai/sd-vae-ft-mse"
|
274 |
+
]
|
275 |
+
print("[BOOT] Loading models from Hub...")
|
276 |
+
Parallel(n_jobs=4)(delayed(load_model)(m) for m in models)
|
277 |
+
# ProPainter wget
|
278 |
+
PROP = Path("/app/propainter")
|
279 |
+
PROP.mkdir(exist_ok=True)
|
280 |
+
def wget(url, dest):
|
281 |
+
if not (dest / url.split("/")[-1]).exists():
|
282 |
+
subprocess.run(["wget", "-q", url, "-P", str(dest)])
|
283 |
+
wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/ProPainter.pth", PROP)
|
284 |
+
wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/raft-things.pth", PROP)
|
285 |
+
wget("https://github.com/sczhou/ProPainter/releases/download/v0.1.0/recurrent_flow_completion.pth", PROP)
|
286 |
+
print("[BOOT] Models ready.")
|
287 |
+
# --- Nouveaux Helpers pour IA/GPU ---
|
288 |
def is_gpu():
|
289 |
import torch
|
290 |
return torch.cuda.is_available()
|
291 |
+
# --- Nouveaux Endpoints pour IA et Améliorations ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
292 |
@app.post("/mask/ai")
|
293 |
async def mask_ai(payload: Dict[str, Any] = Body(...)):
|
294 |
+
if not is_gpu(): raise HTTPException(503, "Switch GPU.")
|
295 |
+
# TODO: Impl SAM2
|
296 |
return {"ok": True, "mask": {"points": [0.1, 0.1, 0.9, 0.9]}}
|
297 |
@app.post("/inpaint")
|
298 |
async def inpaint(payload: Dict[str, Any] = Body(...)):
|
299 |
+
if not is_gpu(): raise HTTPException(503, "Switch GPU.")
|
300 |
+
# TODO: Impl DiffuEraser, update progress_ia
|
301 |
return {"ok": True, "preview": "/data/preview.mp4"}
|
302 |
@app.get("/estimate")
|
303 |
def estimate(vid: str, masks_count: int):
|
304 |
+
# TODO: Calcul (frames * masks * facteur)
|
305 |
+
return {"time_min": 5, "vram_gb": 4}
|
|
|
|
|
306 |
@app.get("/progress_ia")
|
307 |
def progress_ia(vid: str):
|
308 |
+
# TODO: % et logs frame/frame
|
309 |
return {"percent": 0, "log": "En cours..."}
|
310 |
+
# --- Routes existantes (étendues pour multi-masques) ---
|
311 |
+
@app.post("/mask")
|
312 |
+
async def save_mask(payload: Dict[str, Any] = Body(...)):
|
313 |
+
vid = payload.get("vid")
|
314 |
+
if not vid:
|
315 |
+
raise HTTPException(400, "vid manquant")
|
316 |
+
pts = payload.get("points") or []
|
317 |
+
if len(pts) != 4:
|
318 |
+
raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
|
319 |
+
data = _load_masks(vid)
|
320 |
+
m = {
|
321 |
+
"id": uuid.uuid4().hex[:10],
|
322 |
+
"time_s": float(payload.get("time_s") or 0.0),
|
323 |
+
"frame_idx": int(payload.get("frame_idx") or 0),
|
324 |
+
"shape": "rect",
|
325 |
+
"points": [float(x) for x in pts],
|
326 |
+
"color": payload.get("color") or "#10b981",
|
327 |
+
"note": payload.get("note") or ""
|
328 |
+
}
|
329 |
+
data.setdefault("masks", []).append(m)
|
330 |
+
_save_masks(vid, data)
|
331 |
+
print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
|
332 |
+
return {"saved": True, "mask": m}
|
333 |
+
@app.get("/mask/{vid}")
|
334 |
+
def list_masks(vid: str):
|
335 |
+
return _load_masks(vid)
|
336 |
+
@app.post("/mask/rename")
|
337 |
+
async def rename_mask(payload: Dict[str, Any] = Body(...)):
|
338 |
+
vid = payload.get("vid")
|
339 |
+
mid = payload.get("id")
|
340 |
+
new_note = (payload.get("note") or "").strip()
|
341 |
+
if not vid or not mid:
|
342 |
+
raise HTTPException(400, "vid et id requis")
|
343 |
+
data = _load_masks(vid)
|
344 |
+
for m in data.get("masks", []):
|
345 |
+
if m.get("id") == mid:
|
346 |
+
m["note"] = new_note
|
347 |
+
_save_masks(vid, data)
|
348 |
+
return {"ok": True}
|
349 |
+
raise HTTPException(404, "Masque introuvable")
|
350 |
+
@app.post("/mask/delete")
|
351 |
+
async def delete_mask(payload: Dict[str, Any] = Body(...)):
|
352 |
+
vid = payload.get("vid")
|
353 |
+
mid = payload.get("id")
|
354 |
+
if not vid or not mid:
|
355 |
+
raise HTTPException(400, "vid et id requis")
|
356 |
+
data = _load_masks(vid)
|
357 |
+
data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
|
358 |
+
_save_masks(vid, data)
|
359 |
+
return {"ok": True}
|
360 |
+
@app.get("/")
|
361 |
def root():
|
362 |
return {
|
363 |
"ok": True,
|
364 |
+
"routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
|
365 |
}
|
366 |
+
@app.get("/health")
|
367 |
def health():
|
368 |
return {"status": "ok"}
|
369 |
+
@app.get("/_env")
|
370 |
def env_info():
|
371 |
return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
|
372 |
+
@app.get("/files")
|
373 |
def files():
|
374 |
items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
|
375 |
return {"count": len(items), "items": items}
|
376 |
+
@app.get("/meta/{vid}")
|
377 |
def video_meta(vid: str):
|
378 |
v = DATA_DIR / vid
|
379 |
if not v.exists():
|
|
|
382 |
if not m:
|
383 |
raise HTTPException(500, "Métadonnées indisponibles")
|
384 |
return m
|
385 |
+
@app.post("/upload")
|
386 |
async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
|
387 |
ext = (Path(file.filename).suffix or ".mp4").lower()
|
388 |
if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
|
|
|
402 |
msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
|
403 |
return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
|
404 |
return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
|
405 |
+
@app.get("/progress/{vid_stem}")
|
406 |
def progress(vid_stem: str):
|
407 |
return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
|
408 |
+
@app.delete("/delete/{vid}")
|
409 |
def delete_video(vid: str):
|
410 |
v = DATA_DIR / vid
|
411 |
if not v.exists():
|
|
|
417 |
v.unlink(missing_ok=True)
|
418 |
print(f"[DELETE] {vid}", file=sys.stdout)
|
419 |
return {"deleted": vid}
|
420 |
+
@app.get("/frame_idx")
|
421 |
def frame_idx(vid: str, idx: int):
|
422 |
v = DATA_DIR / vid
|
423 |
if not v.exists():
|
|
|
432 |
except Exception as e:
|
433 |
print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
|
434 |
raise HTTPException(500, "Frame error")
|
435 |
+
@app.get("/poster/{vid}")
|
436 |
def poster(vid: str):
|
437 |
v = DATA_DIR / vid
|
438 |
if not v.exists():
|
|
|
441 |
if p.exists():
|
442 |
return FileResponse(str(p), media_type="image/jpeg")
|
443 |
raise HTTPException(404, "Poster introuvable")
|
444 |
+
@app.get("/window/{vid}")
|
445 |
def window(vid: str, center: int = 0, count: int = 21):
|
446 |
v = DATA_DIR / vid
|
447 |
if not v.exists():
|
|
|
468 |
items.append({"i": i, "idx": idx, "url": url})
|
469 |
print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
|
470 |
return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
|
471 |
+
# ---------- UI (étendue pour améliorations) ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
472 |
HTML_TEMPLATE = r"""
|
473 |
<!doctype html>
|
474 |
<html lang="fr"><meta charset="utf-8">
|
|
|
536 |
<input type="file" name="file" accept="video/*" required>
|
537 |
<button class="btn" type="submit">Uploader</button>
|
538 |
</form>
|
|
|
539 |
<span class="muted" id="msg">__MSG__</span>
|
540 |
<span class="muted">Liens : <a href="/docs" target="_blank">/docs</a> • <a href="/files" target="_blank">/files</a></span>
|
541 |
</div>
|
|
|
567 |
<label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
|
568 |
<button id="gotoBtn" class="btn">Aller</button>
|
569 |
<span class="muted" id="maskedCount"></span>
|
570 |
+
<!-- Nouveaux boutons undo/redo (près des contrôles) -->
|
571 |
+
<button id="btnUndo">↩️ Undo</button>
|
572 |
+
<button id="btnRedo">↪️ Redo</button>
|
573 |
</div>
|
574 |
<div id="timeline" class="timeline"></div>
|
575 |
<div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
|
576 |
<div id="loading-indicator">Chargement des frames...</div>
|
577 |
<div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
|
578 |
+
<!-- Nouvelle barre de progression IA (sous timeline) -->
|
579 |
<div id="progressBar"><div id="progressFill"></div></div>
|
580 |
<div>Progression IA: <span id="progressLog">En attente...</span></div>
|
581 |
</div>
|
|
|
587 |
<button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
|
588 |
<button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
|
589 |
<button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
|
590 |
+
<!-- Nouveau bouton preview IA (près de save) -->
|
591 |
+
<button id="btnPreview">🔍 Preview IA</button>
|
592 |
</div>
|
593 |
<div style="margin-top:10px">
|
594 |
<div class="muted">Couleur</div>
|
|
|
619 |
<div id="popup-logs"></div>
|
620 |
</div>
|
621 |
<div id="toast"></div>
|
622 |
+
<!-- Nouveau : Tutoriel masquable (au bas, optionnel) -->
|
623 |
<div id="tutorial" onclick="this.classList.add('hidden')">Tutoriel (cliquer pour masquer)<br>1. Upload vidéo local. 2. Dessine masques. 3. Retouche IA. 4. Export téléchargement.</div>
|
624 |
<script>
|
625 |
const serverVid = "__VID__";
|
|
|
659 |
const toastWrap = document.getElementById('toast');
|
660 |
const gotoInput = document.getElementById('gotoInput');
|
661 |
const gotoBtn = document.getElementById('gotoBtn');
|
662 |
+
// Nouveaux éléments pour améliorations
|
663 |
const btnUndo = document.getElementById('btnUndo');
|
664 |
const btnRedo = document.getElementById('btnRedo');
|
665 |
const btnPreview = document.getElementById('btnPreview');
|
666 |
const progressFill = document.getElementById('progressFill');
|
667 |
const progressLog = document.getElementById('progressLog');
|
|
|
668 |
// State
|
669 |
let vidName = serverVid || '';
|
670 |
function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
|
|
|
1010 |
vidStem = fileStem(vidName); bustToken = Date.now();
|
1011 |
const bust = Date.now();
|
1012 |
srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
|
1013 |
+
player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust;
|
1014 |
player.load();
|
1015 |
fitCanvas();
|
1016 |
statusEl.textContent = 'Chargement vidéo…';
|
|
|
1190 |
alert('Erreur réseau lors de l’enregistrement du masque.');
|
1191 |
}
|
1192 |
};
|
1193 |
+
// Nouveaux : Undo/Redo (pour rects)
|
1194 |
+
btnUndo.onclick = () => {
|
1195 |
+
if (history.length) {
|
1196 |
+
const last = history.pop();
|
1197 |
+
redoStack.push({...rect});
|
1198 |
+
rect = last;
|
1199 |
+
draw();
|
1200 |
+
}
|
1201 |
+
};
|
1202 |
+
btnRedo.onclick = () => {
|
1203 |
+
if (redoStack.length) {
|
1204 |
+
const next = redoStack.pop();
|
1205 |
+
history.push({...rect});
|
1206 |
+
rect = next;
|
1207 |
+
draw();
|
1208 |
+
}
|
1209 |
+
};
|
1210 |
+
canvas.addEventListener('mouseup', () => {
|
1211 |
+
if (dragging) { history.push({...rect}); redoStack = []; }
|
1212 |
+
});
|
1213 |
+
// Nouveau : Preview IA (stub)
|
1214 |
+
btnPreview.onclick = async () => {
|
1215 |
+
if (!vidName) return;
|
1216 |
+
const payload = {vid: vidName}; // TODO: Ajouter masques sélectionnés
|
1217 |
+
const r = await fetch('/inpaint', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
|
1218 |
+
if (r.ok) {
|
1219 |
+
const d = await r.json();
|
1220 |
+
alert('Preview prête : ' + d.preview); // TODO: Popup avec <video> preview
|
1221 |
+
} else {
|
1222 |
+
alert('Échec preview IA');
|
1223 |
+
}
|
1224 |
+
};
|
1225 |
+
// Nouveau : Feedback progression IA (poll every 2s)
|
1226 |
+
function updateProgress() {
|
1227 |
+
if (!vidName) return;
|
1228 |
+
fetch(`/progress_ia?vid=${encodeURIComponent(vidName)}`).then(r => r.json()).then(d => {
|
1229 |
+
progressFill.style.width = `${d.percent}%`;
|
1230 |
+
progressLog.textContent = d.log;
|
1231 |
+
setTimeout(updateProgress, 2000);
|
1232 |
+
});
|
1233 |
+
}
|
1234 |
+
updateProgress();
|
1235 |
+
// Nouveau : Auto-save enhanced (récup au boot si crash)
|
1236 |
async function boot(){
|
1237 |
+
const lst = loadPending(); if (lst.length) { showToast(`Récupération de ${lst.length} masques pending`); await flushPending(); }
|
1238 |
await loadFiles();
|
1239 |
if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
|
1240 |
else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
|
1241 |
+
// Tutoriel : Show if first time
|
1242 |
+
if (!localStorage.getItem('tutorialSeen')) {
|
1243 |
+
document.getElementById('tutorial').style.display = 'block';
|
1244 |
+
localStorage.setItem('tutorialSeen', '1');
|
1245 |
+
}
|
1246 |
}
|
1247 |
boot();
|
1248 |
// Hide controls in edit-mode
|