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
|