FABLESLIP commited on
Commit
be0fd54
·
verified ·
1 Parent(s): c9c1811

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +340 -341
app.py CHANGED
@@ -4,7 +4,7 @@
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 # Corrigé : import au lieu de =
8
  from fastapi.staticfiles import StaticFiles
9
  from pathlib import Path
10
  from typing import Optional, Dict, Any
@@ -19,31 +19,31 @@ POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
19
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
20
  _backend_url_cache = {"url": None, "ts": 0.0}
21
  def get_backend_base() -> str:
22
- """
23
- Renvoie l'URL du backend.
24
- - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
25
- l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
26
- - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
27
- """
28
- try:
29
- if POINTER_URL:
30
- now = time.time()
31
- need_refresh = (
32
- not _backend_url_cache["url"] or
33
- now - _backend_url_cache["ts"] > 30
34
- )
35
- if need_refresh:
36
- r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True) # Ajout pour redirections
37
- url = (r.text or "").strip()
38
- if url.startswith("http"):
39
- _backend_url_cache["url"] = url
40
- _backend_url_cache["ts"] = now
41
- else:
42
- return FALLBACK_BASE
43
- return _backend_url_cache["url"] or FALLBACK_BASE
44
- return FALLBACK_BASE
45
- except Exception:
46
- return FALLBACK_BASE
47
  # ---------------------------------------------------------------------------
48
  print("[BOOT] Video Editor API starting…")
49
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
@@ -53,374 +53,373 @@ DATA_DIR = Path("/app/data")
53
  THUMB_DIR = DATA_DIR / "_thumbs"
54
  MASK_DIR = DATA_DIR / "_masks"
55
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
56
- p.mkdir(parents=True, exist_ok=True)
57
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
58
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
59
  # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) --------------------
60
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
61
  async def proxy_all(full_path: str, request: Request):
62
- base = get_backend_base().rstrip("/")
63
- target = f"{base}/{full_path}"
64
- qs = request.url.query
65
- if qs:
66
- target = f"{target}?{qs}"
67
- body = await request.body()
68
- headers = dict(request.headers)
69
- headers.pop("host", None)
70
- async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
71
- r = await client.request(request.method, target, headers=headers, content=body)
72
- drop = {"content-encoding","transfer-encoding","connection",
73
- "keep-alive","proxy-authenticate","proxy-authorization",
74
- "te","trailers","upgrade"}
75
- out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
76
- return Response(content=r.content, status_code=r.status_code, headers=out_headers)
77
  # -------------------------------------------------------------------------------
78
  # Global progress dict (vid_stem -> {percent, logs, done})
79
  progress_data: Dict[str, Dict[str, Any]] = {}
80
  # ---------- Helpers ----------
81
  def _is_video(p: Path) -> bool:
82
- return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
83
  def _safe_name(name: str) -> str:
84
- return Path(name).name.replace(" ", "_")
85
  def _has_ffmpeg() -> bool:
86
- return _shutil.which("ffmpeg") is not None
87
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
88
- # Utilisation en subprocess (pas shell), on échappe la virgule.
89
- return f"scale=min(iw\\,{max_w}):-2"
90
  def _meta(video: Path):
91
- cap = cv2.VideoCapture(str(video))
92
- if not cap.isOpened():
93
- print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
94
- return None
95
- frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
96
- fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
97
- w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
98
- h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
99
- cap.release()
100
- print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
101
- return {"frames": frames, "fps": fps, "w": w, "h": h}
102
- def _frame_jpg(video: Path, idx: int) -> Path:
103
- """
104
- Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
105
- Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
106
- """
107
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
108
- if out.exists():
109
- return out
110
- if _has_ffmpeg():
111
- m = _meta(video) or {"fps": 30.0}
112
- fps = float(m.get("fps") or 30.0) or 30.0
113
- t = max(0.0, float(idx) / fps)
114
- cmd = [
115
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
116
- "-ss", f"{t:.6f}",
117
- "-i", str(video),
118
- "-frames:v", "1",
119
- "-vf", _ffmpeg_scale_filter(320),
120
- "-q:v", "8",
121
- str(out)
122
- ]
123
- try:
124
- subprocess.run(cmd, check=True)
125
- return out
126
- except subprocess.CalledProcessError as e:
127
- print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
128
- # fallback OpenCV
129
- cap = cv2.VideoCapture(str(video))
130
- if not cap.isOpened():
131
- print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
132
- raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
133
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
134
- if total <= 0:
135
  cap.release()
136
- print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
137
- raise HTTPException(500, "Frame count invalide.")
138
- idx = max(0, min(idx, total - 1))
139
- cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
140
- ok, img = cap.read()
141
- cap.release()
142
- if not ok or img is None:
143
- print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
144
- raise HTTPException(500, "Impossible de lire la frame demandée.")
145
- # Redimension (≈320 px)
146
- h, w = img.shape[:2]
147
- if w > 320:
148
- new_w = 320
149
- new_h = int(h * (320.0 / w)) or 1
150
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
151
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
152
- return out
153
- def _poster(video: Path) -> Path:
154
- out = THUMB_DIR / f"poster_{video.stem}.jpg"
155
- if out.exists():
156
- return out
157
- try:
 
 
 
 
 
 
158
  cap = cv2.VideoCapture(str(video))
159
- cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
 
 
 
 
 
 
 
 
 
160
  ok, img = cap.read()
161
  cap.release()
162
- if ok and img is not None:
163
- cv2.imwrite(str(out), img)
164
- except Exception as e:
165
- print(f"[POSTER] Failed: {e}", file=sys.stdout)
166
- return out
167
- def _mask_file(vid: str) -> Path:
168
- return MASK_DIR / f"{Path(vid).name}.json"
169
- def _load_masks(vid: str) -> Dict[str, Any]:
170
- f = _mask_file(vid)
171
- if f.exists():
 
 
 
 
 
172
  try:
173
- return json.loads(f.read_text(encoding="utf-8"))
 
 
 
 
 
174
  except Exception as e:
175
- print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
176
- return {"video": vid, "masks": []}
 
 
 
 
 
 
 
 
 
 
177
  def _save_masks(vid: str, data: Dict[str, Any]):
178
- _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
179
  def _gen_thumbs_background(video: Path, vid_stem: str):
180
- """
181
- Génère toutes les vignettes en arrière-plan :
182
- - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
183
- - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
184
- """
185
- progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
186
- try:
187
- m = _meta(video)
188
- if not m:
189
- progress_data[vid_stem]['logs'].append("Erreur métadonnées")
190
- progress_data[vid_stem]['done'] = True
191
- return
192
- total_frames = int(m["frames"] or 0)
193
- if total_frames <= 0:
194
- progress_data[vid_stem]['logs'].append("Aucune frame détectée")
195
- progress_data[vid_stem]['done'] = True
196
- return
197
- # Nettoyer d’anciennes thumbs du même stem
198
- for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
199
- f.unlink(missing_ok=True)
200
- if _has_ffmpeg():
201
- out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
202
- cmd = [
203
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
204
- "-i", str(video),
205
- "-vf", _ffmpeg_scale_filter(320),
206
- "-q:v", "8",
207
- "-start_number", "0",
208
- out_tpl
209
- ]
210
- progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
211
- proc = subprocess.Popen(cmd)
212
- last_report = -1
213
- while proc.poll() is None:
214
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
215
- percent = int(min(99, (generated / max(1, total_frames)) * 100))
216
- progress_data[vid_stem]['percent'] = percent
217
- if generated != last_report and generated % 50 == 0:
218
- progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
219
- last_report = generated
220
- time.sleep(0.4)
221
- proc.wait()
222
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
223
- progress_data[vid_stem]['percent'] = 100
224
- progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
225
- progress_data[vid_stem]['done'] = True
226
- print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
227
- else:
228
- progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
229
- cap = cv2.VideoCapture(str(video))
230
- if not cap.isOpened():
231
- progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  progress_data[vid_stem]['done'] = True
233
- return
234
- idx = 0
235
- last_report = -1
236
- while True:
237
- ok, img = cap.read()
238
- if not ok or img is None:
239
- break
240
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
241
- # Redimension léger (≈320 px de large)
242
- h, w = img.shape[:2]
243
- if w > 320:
244
- new_w = 320
245
- new_h = int(h * (320.0 / w)) or 1
246
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
247
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
248
- idx += 1
249
- if idx % 50 == 0:
250
- progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
251
- if idx != last_report:
252
- progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
253
- last_report = idx
254
- cap.release()
255
- progress_data[vid_stem]['percent'] = 100
256
- progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
257
- progress_data[vid_stem]['done'] = True
258
- print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
259
- except Exception as e:
260
- progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
261
- progress_data[vid_stem]['done'] = True
262
  # ---------- API ----------
263
  @app.get("/", tags=["meta"])
264
  def root():
265
- return {
266
- "ok": True,
267
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
268
- }
269
  @app.get("/health", tags=["meta"])
270
  def health():
271
- return {"status": "ok"}
272
  @app.get("/_env", tags=["meta"])
273
  def env_info():
274
- return {"pointer_set": bool(POINTER_URL), "resolved_base": get_backend_base()}
275
  @app.get("/files", tags=["io"])
276
  def files():
277
- items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
278
- return {"count": len(items), "items": items}
279
  @app.get("/meta/{vid}", tags=["io"])
280
  def video_meta(vid: str):
281
- v = DATA_DIR / vid
282
- if not v.exists():
283
- raise HTTPException(404, "Vidéo introuvable")
284
- m = _meta(v)
285
- if not m:
286
- raise HTTPException(500, "Métadonnées indisponibles")
287
- return m
288
  @app.post("/upload", tags=["io"])
289
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
290
- ext = (Path(file.filename).suffix or ".mp4").lower()
291
- if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
292
- raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm")
293
- base = _safe_name(file.filename)
294
- dst = DATA_DIR / base
295
- if dst.exists():
296
- dst = DATA_DIR / f"{Path(base).stem}__{uuid.uuid4().hex[:8]}{ext}"
297
- with dst.open("wb") as f:
298
- shutil.copyfileobj(file.file, f)
299
- print(f"[UPLOAD] Saved {dst.name} ({dst.stat().st_size} bytes)", file=sys.stdout)
300
- _poster(dst)
301
- stem = dst.stem
302
- threading.Thread(target=_gen_thumbs_background, args=(dst, stem), daemon=True).start()
303
- accept = (request.headers.get("accept") or "").lower()
304
- if redirect or "text/html" in accept:
305
- msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
306
- return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
307
- return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
308
  @app.get("/progress/{vid_stem}", tags=["io"])
309
  def progress(vid_stem: str):
310
- return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
311
  @app.delete("/delete/{vid}", tags=["io"])
312
  def delete_video(vid: str):
313
- v = DATA_DIR / vid
314
- if not v.exists():
315
- raise HTTPException(404, "Vidéo introuvable")
316
- (THUMB_DIR / f"poster_{v.stem}.jpg").unlink(missing_ok=True)
317
- for f in THUMB_DIR.glob(f"f_{v.stem}_*.jpg"):
318
- f.unlink(missing_ok=True)
319
- _mask_file(vid).unlink(missing_ok=True)
320
- v.unlink(missing_ok=True)
321
- print(f"[DELETE] {vid}", file=sys.stdout)
322
- return {"deleted": vid}
323
  @app.get("/frame_idx", tags=["io"])
324
  def frame_idx(vid: str, idx: int):
325
- v = DATA_DIR / vid
326
- if not v.exists():
327
- raise HTTPException(404, "Vidéo introuvable")
328
- try:
329
- out = _frame_jpg(v, int(idx))
330
- print(f"[FRAME] OK {vid} idx={idx}", file=sys.stdout)
331
- return FileResponse(str(out), media_type="image/jpeg")
332
- except HTTPException as he:
333
- print(f"[FRAME] FAIL {vid} idx={idx}: {he.detail}", file=sys.stdout)
334
- raise
335
- except Exception as e:
336
- print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
337
- raise HTTPException(500, "Frame error")
338
  @app.get("/poster/{vid}", tags=["io"])
339
  def poster(vid: str):
340
- v = DATA_DIR / vid
341
- if not v.exists():
342
- raise HTTPException(404, "Vidéo introuvable")
343
- p = _poster(v)
344
- if p.exists():
345
- return FileResponse(str(p), media_type="image/jpeg")
346
- raise HTTPException(404, "Poster introuvable")
347
  @app.get("/window/{vid}", tags=["io"])
348
  def window(vid: str, center: int = 0, count: int = 21):
349
- v = DATA_DIR / vid
350
- if not v.exists():
351
- raise HTTPException(404, "Vidéo introuvable")
352
- m = _meta(v)
353
- if not m:
354
- raise HTTPException(500, "Métadonnées indisponibles")
355
- frames = m["frames"]
356
- count = max(3, int(count))
357
- center = max(0, min(int(center), max(0, frames-1)))
358
- if frames <= 0:
359
- print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
360
- return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
361
- if frames <= count:
362
- start = 0; sel = center; n = frames
363
- else:
364
- start = max(0, min(center - (count//2), frames - count))
365
- n = count; sel = center - start
366
- items = []
367
- bust = int(time.time()*1000)
368
- for i in range(n):
369
- idx = start + i
370
- url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
371
- items.append({"i": i, "idx": idx, "url": url})
372
- print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
373
- return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
374
  # ----- Masques -----
375
  @app.post("/mask", tags=["mask"])
376
  async def save_mask(payload: Dict[str, Any] = Body(...)):
377
- vid = payload.get("vid")
378
- if not vid:
379
- raise HTTPException(400, "vid manquant")
380
- pts = payload.get("points") or []
381
- if len(pts) != 4:
382
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
383
- data = _load_masks(vid)
384
- m = {
385
- "id": uuid.uuid4().hex[:10],
386
- "time_s": float(payload.get("time_s") or 0.0),
387
- "frame_idx": int(payload.get("frame_idx") or 0),
388
- "shape": "rect",
389
- "points": [float(x) for x in pts],
390
- "color": payload.get("color") or "#10b981",
391
- "note": payload.get("note") or ""
392
- }
393
- data.setdefault("masks", []).append(m)
394
- _save_masks(vid, data)
395
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
396
- return {"saved": True, "mask": m}
397
  @app.get("/mask/{vid}", tags=["mask"])
398
  def list_masks(vid: str):
399
- return _load_masks(vid)
400
  @app.post("/mask/rename", tags=["mask"])
401
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
402
- vid = payload.get("vid")
403
- mid = payload.get("id")
404
- new_note = (payload.get("note") or "").strip()
405
- if not vid or not mid:
406
- raise HTTPException(400, "vid et id requis")
407
- data = _load_masks(vid)
408
- for m in data.get("masks", []):
409
- if m.get("id") == mid:
410
- m["note"] = new_note
411
- _save_masks(vid, data)
412
- return {"ok": True}
413
- raise HTTPException(404, "Masque introuvable")
414
  @app.post("/mask/delete", tags=["mask"])
415
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
416
- vid = payload.get("vid")
417
- mid = payload.get("id")
418
- if not vid or not mid:
419
- raise HTTPException(400, "vid et id requis")
420
- data = _load_masks(vid)
421
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
422
- _save_masks(vid, data)
423
- return {"ok": True}
424
  # ---------- UI ----------
425
  HTML_TEMPLATE = r"""
426
  <!doctype html>
@@ -465,7 +464,7 @@ HTML_TEMPLATE = r"""
465
  .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
466
  .portion-input{width:70px}
467
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
468
- #tl-progress-fill{background:#2563eb;height:100%;width:0;border-radius:4px}
469
  #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}
470
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
471
  #popup-progress-fill{background:#2563eb;height:100%;width=0;border-radius:4px}
@@ -479,7 +478,7 @@ HTML_TEMPLATE = r"""
479
  </style>
480
  <h1>🎬 Video Editor</h1>
481
  <div class="topbar card">
482
- <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm"> # Changé pour local
483
  <strong>Charger une vidéo :</strong>
484
  <input type="file" name="file" accept="video/*" required>
485
  <button class="btn" type="submit">Uploader</button>
 
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
9
  from pathlib import Path
10
  from typing import Optional, Dict, Any
 
19
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
20
  _backend_url_cache = {"url": None, "ts": 0.0}
21
  def get_backend_base() -> str:
22
+ """
23
+ Renvoie l'URL du backend.
24
+ - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
25
+ l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
26
+ - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
27
+ """
28
+ try:
29
+ if POINTER_URL:
30
+ now = time.time()
31
+ need_refresh = (
32
+ not _backend_url_cache["url"] or
33
+ now - _backend_url_cache["ts"] > 30
34
+ )
35
+ if need_refresh:
36
+ r = httpx.get(POINTER_URL, timeout=5, follow_redirects=True)
37
+ url = (r.text or "").strip()
38
+ if url.startswith("http"):
39
+ _backend_url_cache["url"] = url
40
+ _backend_url_cache["ts"] = now
41
+ else:
42
+ return FALLBACK_BASE
43
+ return _backend_url_cache["url"] or FALLBACK_BASE
44
+ return FALLBACK_BASE
45
+ except Exception:
46
+ return FALLBACK_BASE
47
  # ---------------------------------------------------------------------------
48
  print("[BOOT] Video Editor API starting…")
49
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
 
53
  THUMB_DIR = DATA_DIR / "_thumbs"
54
  MASK_DIR = DATA_DIR / "_masks"
55
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
56
+ p.mkdir(parents=True, exist_ok=True)
57
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
58
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
59
  # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) --------------------
60
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
61
  async def proxy_all(full_path: str, request: Request):
62
+ base = get_backend_base().rstrip("/")
63
+ target = f"{base}/{full_path}"
64
+ qs = request.url.query
65
+ if qs:
66
+ target = f"{target}?{qs}"
67
+ body = await request.body()
68
+ headers = dict(request.headers)
69
+ headers.pop("host", None)
70
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
71
+ r = await client.request(request.method, target, headers=headers, content=body)
72
+ drop = {"content-encoding","transfer-encoding","connection",
73
+ "keep-alive","proxy-authenticate","proxy-authorization",
74
+ "te","trailers","upgrade"}
75
+ out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
76
+ return Response(content=r.content, status_code=r.status_code, headers=out_headers)
77
  # -------------------------------------------------------------------------------
78
  # Global progress dict (vid_stem -> {percent, logs, done})
79
  progress_data: Dict[str, Dict[str, Any]] = {}
80
  # ---------- Helpers ----------
81
  def _is_video(p: Path) -> bool:
82
+ return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
83
  def _safe_name(name: str) -> str:
84
+ return Path(name).name.replace(" ", "_")
85
  def _has_ffmpeg() -> bool:
86
+ return _shutil.which("ffmpeg") is not None
87
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
88
+ # Utilisation en subprocess (pas shell), on échappe la virgule.
89
+ return f"scale=min(iw\\,{max_w}):-2"
90
  def _meta(video: Path):
91
+ cap = cv2.VideoCapture(str(video))
92
+ if not cap.isOpened():
93
+ print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
94
+ return None
95
+ frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
96
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
97
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
98
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  cap.release()
100
+ print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
101
+ return {"frames": frames, "fps": fps, "w": w, "h": h}
102
+ def _frame_jpg(video: Path, idx: int) -> Path:
103
+ """
104
+ Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
105
+ Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
106
+ """
107
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
108
+ if out.exists():
109
+ return out
110
+ if _has_ffmpeg():
111
+ m = _meta(video) or {"fps": 30.0}
112
+ fps = float(m.get("fps") or 30.0) or 30.0
113
+ t = max(0.0, float(idx) / fps)
114
+ cmd = [
115
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
116
+ "-ss", f"{t:.6f}",
117
+ "-i", str(video),
118
+ "-frames:v", "1",
119
+ "-vf", _ffmpeg_scale_filter(320),
120
+ "-q:v", "8",
121
+ str(out)
122
+ ]
123
+ try:
124
+ subprocess.run(cmd, check=True)
125
+ return out
126
+ except subprocess.CalledProcessError as e:
127
+ print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
128
  cap = cv2.VideoCapture(str(video))
129
+ if not cap.isOpened():
130
+ print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
131
+ raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
132
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
133
+ if total <= 0:
134
+ cap.release()
135
+ print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
136
+ raise HTTPException(500, "Frame count invalide.")
137
+ idx = max(0, min(idx, total - 1))
138
+ cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
139
  ok, img = cap.read()
140
  cap.release()
141
+ if not ok or img is None:
142
+ print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
143
+ raise HTTPException(500, "Impossible de lire la frame demandée.")
144
+ # Redimension (≈320 px)
145
+ h, w = img.shape[:2]
146
+ if w > 320:
147
+ new_w = 320
148
+ new_h = int(h * (320.0 / w)) or 1
149
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
150
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
151
+ return out
152
+ def _poster(video: Path) -> Path:
153
+ out = THUMB_DIR / f"poster_{video.stem}.jpg"
154
+ if out.exists():
155
+ return out
156
  try:
157
+ cap = cv2.VideoCapture(str(video))
158
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
159
+ ok, img = cap.read()
160
+ cap.release()
161
+ if ok and img is not None:
162
+ cv2.imwrite(str(out), img)
163
  except Exception as e:
164
+ print(f"[POSTER] Failed: {e}", file=sys.stdout)
165
+ return out
166
+ def _mask_file(vid: str) -> Path:
167
+ return MASK_DIR / f"{Path(vid).name}.json"
168
+ def _load_masks(vid: str) -> Dict[str, Any]:
169
+ f = _mask_file(vid)
170
+ if f.exists():
171
+ try:
172
+ return json.loads(f.read_text(encoding="utf-8"))
173
+ except Exception as e:
174
+ print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
175
+ return {"video": vid, "masks": []}
176
  def _save_masks(vid: str, data: Dict[str, Any]):
177
+ _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
178
  def _gen_thumbs_background(video: Path, vid_stem: str):
179
+ """
180
+ Génère toutes les vignettes en arrière-plan :
181
+ - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
182
+ - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
183
+ """
184
+ progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
185
+ try:
186
+ m = _meta(video)
187
+ if not m:
188
+ progress_data[vid_stem]['logs'].append("Erreur métadonnées")
189
+ progress_data[vid_stem]['done'] = True
190
+ return
191
+ total_frames = int(m["frames"] or 0)
192
+ if total_frames <= 0:
193
+ progress_data[vid_stem]['logs'].append("Aucune frame détectée")
194
+ progress_data[vid_stem]['done'] = True
195
+ return
196
+ # Nettoyer d’anciennes thumbs du même stem
197
+ for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
198
+ f.unlink(missing_ok=True)
199
+ if _has_ffmpeg():
200
+ out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
201
+ cmd = [
202
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
203
+ "-i", str(video),
204
+ "-vf", _ffmpeg_scale_filter(320),
205
+ "-q:v", "8",
206
+ "-start_number", "0",
207
+ out_tpl
208
+ ]
209
+ progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
210
+ proc = subprocess.Popen(cmd)
211
+ last_report = -1
212
+ while proc.poll() is None:
213
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
214
+ percent = int(min(99, (generated / max(1, total_frames)) * 100))
215
+ progress_data[vid_stem]['percent'] = percent
216
+ if generated != last_report and generated % 50 == 0:
217
+ progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
218
+ last_report = generated
219
+ time.sleep(0.4)
220
+ proc.wait()
221
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
222
+ progress_data[vid_stem]['percent'] = 100
223
+ progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
224
+ progress_data[vid_stem]['done'] = True
225
+ print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
226
+ else:
227
+ progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
228
+ cap = cv2.VideoCapture(str(video))
229
+ if not cap.isOpened():
230
+ progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
231
+ progress_data[vid_stem]['done'] = True
232
+ return
233
+ idx = 0
234
+ last_report = -1
235
+ while True:
236
+ ok, img = cap.read()
237
+ if not ok or img is None:
238
+ break
239
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
240
+ # Redimension léger (≈320 px de large)
241
+ h, w = img.shape[:2]
242
+ if w > 320:
243
+ new_w = 320
244
+ new_h = int(h * (320.0 / w)) or 1
245
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
246
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
247
+ idx += 1
248
+ if idx % 50 == 0:
249
+ progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
250
+ if idx != last_report:
251
+ progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
252
+ last_report = idx
253
+ cap.release()
254
+ progress_data[vid_stem]['percent'] = 100
255
+ progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
256
+ progress_data[vid_stem]['done'] = True
257
+ print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
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():
282
+ raise HTTPException(404, "Vidéo introuvable")
283
+ m = _meta(v)
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"}:
291
+ raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm")
292
+ base = _safe_name(file.filename)
293
+ dst = DATA_DIR / base
294
+ if dst.exists():
295
+ dst = DATA_DIR / f"{Path(base).stem}__{uuid.uuid4().hex[:8]}{ext}"
296
+ with dst.open("wb") as f:
297
+ shutil.copyfileobj(file.file, f)
298
+ print(f"[UPLOAD] Saved {dst.name} ({dst.stat().st_size} bytes)", file=sys.stdout)
299
+ _poster(dst)
300
+ stem = dst.stem
301
+ threading.Thread(target=_gen_thumbs_background, args=(dst, stem), daemon=True).start()
302
+ accept = (request.headers.get("accept") or "").lower()
303
+ if redirect or "text/html" in accept:
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():
314
+ raise HTTPException(404, "Vidéo introuvable")
315
+ (THUMB_DIR / f"poster_{v.stem}.jpg").unlink(missing_ok=True)
316
+ for f in THUMB_DIR.glob(f"f_{v.stem}_*.jpg"):
317
+ f.unlink(missing_ok=True)
318
+ _mask_file(vid).unlink(missing_ok=True)
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():
326
+ raise HTTPException(404, "Vidéo introuvable")
327
+ try:
328
+ out = _frame_jpg(v, int(idx))
329
+ print(f"[FRAME] OK {vid} idx={idx}", file=sys.stdout)
330
+ return FileResponse(str(out), media_type="image/jpeg")
331
+ except HTTPException as he:
332
+ print(f"[FRAME] FAIL {vid} idx={idx}: {he.detail}", file=sys.stdout)
333
+ raise
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():
341
+ raise HTTPException(404, "Vidéo introuvable")
342
+ p = _poster(v)
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():
350
+ raise HTTPException(404, "Vidéo introuvable")
351
+ m = _meta(v)
352
+ if not m:
353
+ raise HTTPException(500, "Métadonnées indisponibles")
354
+ frames = m["frames"]
355
+ count = max(3, int(count))
356
+ center = max(0, min(int(center), max(0, frames-1)))
357
+ if frames <= 0:
358
+ print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
359
+ return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
360
+ if frames <= count:
361
+ start = 0; sel = center; n = frames
362
+ else:
363
+ start = max(0, min(center - (count//2), frames - count))
364
+ n = count; sel = center - start
365
+ items = []
366
+ bust = int(time.time()*1000)
367
+ for i in range(n):
368
+ idx = start + i
369
+ url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
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>
 
464
  .portion-row{display:flex;gap:6px;align-items:center;margin-top:8px}
465
  .portion-input{width:70px}
466
  #tl-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin-top:10px}
467
+ #tl-progress-fill{background:#2563eb;height:100%;width=0;border-radius:4px}
468
  #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}
469
  #popup-progress-bar{background:#f3f4f6;border-radius:4px;height:8px;margin:10px 0}
470
  #popup-progress-fill{background:#2563eb;height:100%;width=0;border-radius:4px}
 
478
  </style>
479
  <h1>🎬 Video Editor</h1>
480
  <div class="topbar card">
481
+ <form action="/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
482
  <strong>Charger une vidéo :</strong>
483
  <input type="file" name="file" accept="video/*" required>
484
  <button class="btn" type="submit">Uploader</button>