Update app.py
Browse files
app.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
#
|
| 4 |
-
#
|
|
|
|
| 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,7 +48,7 @@ def get_backend_base() -> str:
|
|
| 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.
|
| 51 |
DATA_DIR = Path("/app/data")
|
| 52 |
THUMB_DIR = DATA_DIR / "_thumbs"
|
| 53 |
MASK_DIR = DATA_DIR / "_masks"
|
|
@@ -257,123 +258,24 @@ def _gen_thumbs_background(video: Path, vid_stem: str):
|
|
| 257 |
except Exception as e:
|
| 258 |
progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
|
| 259 |
progress_data[vid_stem]['done'] = True
|
| 260 |
-
#
|
| 261 |
-
|
| 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,7 +284,7 @@ def video_meta(vid: str):
|
|
| 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,10 +304,10 @@ async def upload(request: Request, file: UploadFile = File(...), redirect: Optio
|
|
| 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,7 +319,7 @@ def delete_video(vid: str):
|
|
| 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,7 +334,7 @@ def frame_idx(vid: str, idx: int):
|
|
| 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,7 +343,7 @@ def poster(vid: str):
|
|
| 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,7 +370,57 @@ def window(vid: str, center: int = 0, count: int = 21):
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
HTML_TEMPLATE = r"""
|
| 473 |
<!doctype html>
|
| 474 |
<html lang="fr"><meta charset="utf-8">
|
|
@@ -567,17 +519,11 @@ HTML_TEMPLATE = r"""
|
|
| 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>
|
| 582 |
</div>
|
| 583 |
<div class="card tools">
|
|
@@ -587,8 +533,6 @@ HTML_TEMPLATE = r"""
|
|
| 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,8 +563,6 @@ HTML_TEMPLATE = r"""
|
|
| 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__";
|
| 626 |
const serverMsg = "__MSG__";
|
|
@@ -659,12 +601,6 @@ const hud = document.getElementById('hud');
|
|
| 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; }
|
|
@@ -693,9 +629,6 @@ let lastCenterMs = 0;
|
|
| 693 |
const CENTER_THROTTLE_MS = 150;
|
| 694 |
const PENDING_KEY = 've_pending_masks_v1';
|
| 695 |
let maskedOnlyMode = false;
|
| 696 |
-
// Nouveaux pour undo/redo
|
| 697 |
-
let history = [];
|
| 698 |
-
let redoStack = [];
|
| 699 |
// Utils
|
| 700 |
function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
|
| 701 |
function ensureOverlays(){
|
|
@@ -1010,7 +943,7 @@ async function loadVideoAndMeta() {
|
|
| 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…';
|
|
@@ -1105,7 +1038,7 @@ async function loadFiles(){
|
|
| 1105 |
d.items.forEach(name=>{
|
| 1106 |
const li=document.createElement('li');
|
| 1107 |
const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
|
| 1108 |
-
delBtn.onclick=async
|
| 1109 |
if(!confirm(`Supprimer "${name}" ?`)) return;
|
| 1110 |
await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
|
| 1111 |
loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
|
|
@@ -1141,7 +1074,7 @@ async function loadMasks(){
|
|
| 1141 |
const label=m.note||(`frame ${fr}`);
|
| 1142 |
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`;
|
| 1143 |
const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
|
| 1144 |
-
renameBtn.onclick=async
|
| 1145 |
const nv=prompt('Nouveau nom du masque :', label);
|
| 1146 |
if(nv===null) return;
|
| 1147 |
const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
|
|
@@ -1150,7 +1083,7 @@ async function loadMasks(){
|
|
| 1150 |
}
|
| 1151 |
};
|
| 1152 |
const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
|
| 1153 |
-
delMaskBtn.onclick=async
|
| 1154 |
if(!confirm(`Supprimer masque "${label}" ?`)) return;
|
| 1155 |
const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
|
| 1156 |
if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
|
|
@@ -1190,59 +1123,12 @@ btnSave.onclick = async ()=>{
|
|
| 1190 |
alert('Erreur réseau lors de l’enregistrement du masque.');
|
| 1191 |
}
|
| 1192 |
};
|
| 1193 |
-
//
|
| 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 =
|
| 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
|
|
|
|
| 1 |
+
# app.py — Video Editor API (v0.5.9)
|
| 2 |
+
# v0.5.9:
|
| 3 |
+
# - Centrages "Aller à #" / "Frame #" 100% fiables (attend rendu + image chargée)
|
| 4 |
+
# - /mask, /mask/rename, /mask/delete : Body(...) explicite => plus de 422 silencieux
|
| 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 |
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.5.9")
|
| 52 |
DATA_DIR = Path("/app/data")
|
| 53 |
THUMB_DIR = DATA_DIR / "_thumbs"
|
| 54 |
MASK_DIR = DATA_DIR / "_masks"
|
|
|
|
| 258 |
except Exception as e:
|
| 259 |
progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
|
| 260 |
progress_data[vid_stem]['done'] = True
|
| 261 |
+
# ---------- API ----------
|
| 262 |
+
@app.get("/", tags=["meta"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
def root():
|
| 264 |
return {
|
| 265 |
"ok": True,
|
| 266 |
"routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
|
| 267 |
}
|
| 268 |
+
@app.get("/health", tags=["meta"])
|
| 269 |
def health():
|
| 270 |
return {"status": "ok"}
|
| 271 |
+
@app.get("/_env", tags=["meta"])
|
| 272 |
def env_info():
|
| 273 |
return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
|
| 274 |
+
@app.get("/files", tags=["io"])
|
| 275 |
def files():
|
| 276 |
items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
|
| 277 |
return {"count": len(items), "items": items}
|
| 278 |
+
@app.get("/meta/{vid}", tags=["io"])
|
| 279 |
def video_meta(vid: str):
|
| 280 |
v = DATA_DIR / vid
|
| 281 |
if not v.exists():
|
|
|
|
| 284 |
if not m:
|
| 285 |
raise HTTPException(500, "Métadonnées indisponibles")
|
| 286 |
return m
|
| 287 |
+
@app.post("/upload", tags=["io"])
|
| 288 |
async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
|
| 289 |
ext = (Path(file.filename).suffix or ".mp4").lower()
|
| 290 |
if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
|
|
|
|
| 304 |
msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
|
| 305 |
return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
|
| 306 |
return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
|
| 307 |
+
@app.get("/progress/{vid_stem}", tags=["io"])
|
| 308 |
def progress(vid_stem: str):
|
| 309 |
return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
|
| 310 |
+
@app.delete("/delete/{vid}", tags=["io"])
|
| 311 |
def delete_video(vid: str):
|
| 312 |
v = DATA_DIR / vid
|
| 313 |
if not v.exists():
|
|
|
|
| 319 |
v.unlink(missing_ok=True)
|
| 320 |
print(f"[DELETE] {vid}", file=sys.stdout)
|
| 321 |
return {"deleted": vid}
|
| 322 |
+
@app.get("/frame_idx", tags=["io"])
|
| 323 |
def frame_idx(vid: str, idx: int):
|
| 324 |
v = DATA_DIR / vid
|
| 325 |
if not v.exists():
|
|
|
|
| 334 |
except Exception as e:
|
| 335 |
print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
|
| 336 |
raise HTTPException(500, "Frame error")
|
| 337 |
+
@app.get("/poster/{vid}", tags=["io"])
|
| 338 |
def poster(vid: str):
|
| 339 |
v = DATA_DIR / vid
|
| 340 |
if not v.exists():
|
|
|
|
| 343 |
if p.exists():
|
| 344 |
return FileResponse(str(p), media_type="image/jpeg")
|
| 345 |
raise HTTPException(404, "Poster introuvable")
|
| 346 |
+
@app.get("/window/{vid}", tags=["io"])
|
| 347 |
def window(vid: str, center: int = 0, count: int = 21):
|
| 348 |
v = DATA_DIR / vid
|
| 349 |
if not v.exists():
|
|
|
|
| 370 |
items.append({"i": i, "idx": idx, "url": url})
|
| 371 |
print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
|
| 372 |
return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
|
| 373 |
+
# ----- Masques -----
|
| 374 |
+
@app.post("/mask", tags=["mask"])
|
| 375 |
+
async def save_mask(payload: Dict[str, Any] = Body(...)):
|
| 376 |
+
vid = payload.get("vid")
|
| 377 |
+
if not vid:
|
| 378 |
+
raise HTTPException(400, "vid manquant")
|
| 379 |
+
pts = payload.get("points") or []
|
| 380 |
+
if len(pts) != 4:
|
| 381 |
+
raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
|
| 382 |
+
data = _load_masks(vid)
|
| 383 |
+
m = {
|
| 384 |
+
"id": uuid.uuid4().hex[:10],
|
| 385 |
+
"time_s": float(payload.get("time_s") or 0.0),
|
| 386 |
+
"frame_idx": int(payload.get("frame_idx") or 0),
|
| 387 |
+
"shape": "rect",
|
| 388 |
+
"points": [float(x) for x in pts],
|
| 389 |
+
"color": payload.get("color") or "#10b981",
|
| 390 |
+
"note": payload.get("note") or ""
|
| 391 |
+
}
|
| 392 |
+
data.setdefault("masks", []).append(m)
|
| 393 |
+
_save_masks(vid, data)
|
| 394 |
+
print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
|
| 395 |
+
return {"saved": True, "mask": m}
|
| 396 |
+
@app.get("/mask/{vid}", tags=["mask"])
|
| 397 |
+
def list_masks(vid: str):
|
| 398 |
+
return _load_masks(vid)
|
| 399 |
+
@app.post("/mask/rename", tags=["mask"])
|
| 400 |
+
async def rename_mask(payload: Dict[str, Any] = Body(...)):
|
| 401 |
+
vid = payload.get("vid")
|
| 402 |
+
mid = payload.get("id")
|
| 403 |
+
new_note = (payload.get("note") or "").strip()
|
| 404 |
+
if not vid or not mid:
|
| 405 |
+
raise HTTPException(400, "vid et id requis")
|
| 406 |
+
data = _load_masks(vid)
|
| 407 |
+
for m in data.get("masks", []):
|
| 408 |
+
if m.get("id") == mid:
|
| 409 |
+
m["note"] = new_note
|
| 410 |
+
_save_masks(vid, data)
|
| 411 |
+
return {"ok": True}
|
| 412 |
+
raise HTTPException(404, "Masque introuvable")
|
| 413 |
+
@app.post("/mask/delete", tags=["mask"])
|
| 414 |
+
async def delete_mask(payload: Dict[str, Any] = Body(...)):
|
| 415 |
+
vid = payload.get("vid")
|
| 416 |
+
mid = payload.get("id")
|
| 417 |
+
if not vid or not mid:
|
| 418 |
+
raise HTTPException(400, "vid et id requis")
|
| 419 |
+
data = _load_masks(vid)
|
| 420 |
+
data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
|
| 421 |
+
_save_masks(vid, data)
|
| 422 |
+
return {"ok": True}
|
| 423 |
+
# ---------- UI ----------
|
| 424 |
HTML_TEMPLATE = r"""
|
| 425 |
<!doctype html>
|
| 426 |
<html lang="fr"><meta charset="utf-8">
|
|
|
|
| 519 |
<label class="muted">Aller à # <input id="gotoInput" type="number" min="1" style="width:90px"></label>
|
| 520 |
<button id="gotoBtn" class="btn">Aller</button>
|
| 521 |
<span class="muted" id="maskedCount"></span>
|
|
|
|
|
|
|
|
|
|
| 522 |
</div>
|
| 523 |
<div id="timeline" class="timeline"></div>
|
| 524 |
<div class="muted" id="tlNote" style="margin-top:6px;display:none">Mode secours: vignettes générées dans le navigateur.</div>
|
| 525 |
<div id="loading-indicator">Chargement des frames...</div>
|
| 526 |
<div id="tl-progress-bar"><div id="tl-progress-fill"></div></div>
|
|
|
|
|
|
|
|
|
|
| 527 |
</div>
|
| 528 |
</div>
|
| 529 |
<div class="card tools">
|
|
|
|
| 533 |
<button id="btnBack" class="btn" style="display:none" title="Retour au mode lecture">🎬 Retour lecteur</button>
|
| 534 |
<button id="btnSave" class="btn" style="display:none" title="Enregistrer le masque sélectionné">💾 Enregistrer masque</button>
|
| 535 |
<button id="btnClear" class="btn" style="display:none" title="Effacer la sélection actuelle">🧽 Effacer sélection</button>
|
|
|
|
|
|
|
| 536 |
</div>
|
| 537 |
<div style="margin-top:10px">
|
| 538 |
<div class="muted">Couleur</div>
|
|
|
|
| 563 |
<div id="popup-logs"></div>
|
| 564 |
</div>
|
| 565 |
<div id="toast"></div>
|
|
|
|
|
|
|
| 566 |
<script>
|
| 567 |
const serverVid = "__VID__";
|
| 568 |
const serverMsg = "__MSG__";
|
|
|
|
| 601 |
const toastWrap = document.getElementById('toast');
|
| 602 |
const gotoInput = document.getElementById('gotoInput');
|
| 603 |
const gotoBtn = document.getElementById('gotoBtn');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
// State
|
| 605 |
let vidName = serverVid || '';
|
| 606 |
function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
|
|
|
|
| 629 |
const CENTER_THROTTLE_MS = 150;
|
| 630 |
const PENDING_KEY = 've_pending_masks_v1';
|
| 631 |
let maskedOnlyMode = false;
|
|
|
|
|
|
|
|
|
|
| 632 |
// Utils
|
| 633 |
function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
|
| 634 |
function ensureOverlays(){
|
|
|
|
| 943 |
vidStem = fileStem(vidName); bustToken = Date.now();
|
| 944 |
const bust = Date.now();
|
| 945 |
srcEl.src = '/data/'+encodeURIComponent(vidName)+'?t='+bust;
|
| 946 |
+
player.setAttribute('poster', '/poster/'+encodeURIComponent(vidName)+'?t='+bust);
|
| 947 |
player.load();
|
| 948 |
fitCanvas();
|
| 949 |
statusEl.textContent = 'Chargement vidéo…';
|
|
|
|
| 1038 |
d.items.forEach(name=>{
|
| 1039 |
const li=document.createElement('li');
|
| 1040 |
const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
|
| 1041 |
+
delBtn.onclick=async=>{
|
| 1042 |
if(!confirm(`Supprimer "${name}" ?`)) return;
|
| 1043 |
await fetch('/delete/'+encodeURIComponent(name),{method:'DELETE'});
|
| 1044 |
loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
|
|
|
|
| 1074 |
const label=m.note||(`frame ${fr}`);
|
| 1075 |
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`;
|
| 1076 |
const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
|
| 1077 |
+
renameBtn.onclick=async=>{
|
| 1078 |
const nv=prompt('Nouveau nom du masque :', label);
|
| 1079 |
if(nv===null) return;
|
| 1080 |
const rr=await fetch('/mask/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
|
|
|
|
| 1083 |
}
|
| 1084 |
};
|
| 1085 |
const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
|
| 1086 |
+
delMaskBtn.onclick=async=>{
|
| 1087 |
if(!confirm(`Supprimer masque "${label}" ?`)) return;
|
| 1088 |
const rr=await fetch('/mask/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
|
| 1089 |
if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
|
|
|
|
| 1123 |
alert('Erreur réseau lors de l’enregistrement du masque.');
|
| 1124 |
}
|
| 1125 |
};
|
| 1126 |
+
// Boot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
async function boot(){
|
| 1128 |
+
const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
|
| 1129 |
await loadFiles();
|
| 1130 |
if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
|
| 1131 |
else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1132 |
}
|
| 1133 |
boot();
|
| 1134 |
// Hide controls in edit-mode
|