FABLESLIP commited on
Commit
cb113a6
·
verified ·
1 Parent(s): abf19cd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +737 -806
app.py CHANGED
@@ -3,468 +3,424 @@
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
-
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Body, Response
8
- from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
9
- from fastapi.staticfiles import StaticFiles
10
  from pathlib import Path
11
  from typing import Optional, Dict, Any
12
  import uuid, shutil, cv2, json, time, urllib.parse, sys
13
  import threading
14
  import subprocess
15
  import shutil as _shutil
16
-
17
  # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------
18
  import os
19
  import httpx
20
-
21
  POINTER_URL = os.getenv("BACKEND_POINTER_URL", "").strip()
22
  FALLBACK_BASE = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8765").strip()
23
-
24
  _backend_url_cache = {"url": None, "ts": 0.0}
25
-
26
  def get_backend_base() -> str:
27
- """
28
- Renvoie l'URL du backend.
29
- - Si BACKEND_POINTER_URL est défini (lien vers un petit fichier texte contenant
30
- l’URL publique actuelle du backend), on lit le contenu et on le met en cache 30 s.
31
- - Sinon on utilise FALLBACK_BASE (par défaut 127.0.0.1:8765).
32
- """
33
- try:
34
- if POINTER_URL:
35
- now = time.time()
36
- need_refresh = (
37
- not _backend_url_cache["url"] or
38
- now - _backend_url_cache["ts"] > 30
39
- )
40
- if need_refresh:
41
- r = httpx.get(POINTER_URL, timeout=5)
42
- url = (r.text or "").strip()
43
- if url.startswith("http"):
44
- _backend_url_cache["url"] = url
45
- _backend_url_cache["ts"] = now
46
- else:
47
- return FALLBACK_BASE
48
- return _backend_url_cache["url"] or FALLBACK_BASE
49
- return FALLBACK_BASE
50
- except Exception:
51
- return FALLBACK_BASE
52
  # ---------------------------------------------------------------------------
53
  print("[BOOT] Video Editor API starting…")
54
  print(f"[BOOT] POINTER_URL={POINTER_URL or '(unset)'}")
55
  print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
56
-
57
  app = FastAPI(title="Video Editor API", version="0.5.9")
58
-
59
  DATA_DIR = Path("/app/data")
60
  THUMB_DIR = DATA_DIR / "_thumbs"
61
  MASK_DIR = DATA_DIR / "_masks"
62
  for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
63
- p.mkdir(parents=True, exist_ok=True)
64
-
65
  app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
66
  app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
67
-
68
  # --- PROXY VERS LE BACKEND (pas de CORS côté navigateur) --------------------
69
  @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
70
  async def proxy_all(full_path: str, request: Request):
71
- base = get_backend_base().rstrip("/")
72
- target = f"{base}/{full_path}"
73
- qs = request.url.query
74
- if qs:
75
- target = f"{target}?{qs}"
76
-
77
- body = await request.body()
78
- headers = dict(request.headers)
79
- headers.pop("host", None)
80
-
81
- async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
82
- r = await client.request(request.method, target, headers=headers, content=body)
83
-
84
- drop = {"content-encoding","transfer-encoding","connection",
85
- "keep-alive","proxy-authenticate","proxy-authorization",
86
- "te","trailers","upgrade"}
87
- out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
88
- return Response(content=r.content, status_code=r.status_code, headers=out_headers)
89
  # -------------------------------------------------------------------------------
90
-
91
  # Global progress dict (vid_stem -> {percent, logs, done})
92
  progress_data: Dict[str, Dict[str, Any]] = {}
93
-
94
  # ---------- Helpers ----------
95
  def _is_video(p: Path) -> bool:
96
- return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
97
-
98
  def _safe_name(name: str) -> str:
99
- return Path(name).name.replace(" ", "_")
100
-
101
  def _has_ffmpeg() -> bool:
102
- return _shutil.which("ffmpeg") is not None
103
-
104
  def _ffmpeg_scale_filter(max_w: int = 320) -> str:
105
- # Utilisation en subprocess (pas shell), on échappe la virgule.
106
- return f"scale=min(iw\\,{max_w}):-2"
107
-
108
  def _meta(video: Path):
109
- cap = cv2.VideoCapture(str(video))
110
- if not cap.isOpened():
111
- print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
112
- return None
113
- frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
114
- fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
115
- w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
116
- h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
117
- cap.release()
118
- print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
119
- return {"frames": frames, "fps": fps, "w": w, "h": h}
120
-
121
  def _frame_jpg(video: Path, idx: int) -> Path:
122
- """
123
- Crée (si besoin) et renvoie le chemin de la miniature d'index idx.
124
- Utilise FFmpeg pour seek rapide si disponible, sinon OpenCV.
125
- """
126
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
127
- if out.exists():
128
- return out
129
-
130
- if _has_ffmpeg():
131
- m = _meta(video) or {"fps": 30.0}
132
- fps = float(m.get("fps") or 30.0) or 30.0
133
- t = max(0.0, float(idx) / fps)
134
- cmd = [
135
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
136
- "-ss", f"{t:.6f}",
137
- "-i", str(video),
138
- "-frames:v", "1",
139
- "-vf", _ffmpeg_scale_filter(320),
140
- "-q:v", "8",
141
- str(out)
142
- ]
143
- try:
144
- subprocess.run(cmd, check=True)
145
- return out
146
- except subprocess.CalledProcessError as e:
147
- print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
148
- # fallback OpenCV
149
-
150
- cap = cv2.VideoCapture(str(video))
151
- if not cap.isOpened():
152
- print(f"[FRAME] Cannot open video for frames: {video}", file=sys.stdout)
153
- raise HTTPException(500, "OpenCV ne peut pas ouvrir la vidéo.")
154
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
155
- if total <= 0:
156
- cap.release()
157
- print(f"[FRAME] Frame count invalid for: {video}", file=sys.stdout)
158
- raise HTTPException(500, "Frame count invalide.")
159
- idx = max(0, min(idx, total - 1))
160
- cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
161
- ok, img = cap.read()
162
- cap.release()
163
- if not ok or img is None:
164
- print(f"[FRAME] Cannot read idx={idx} for: {video}", file=sys.stdout)
165
- raise HTTPException(500, "Impossible de lire la frame demandée.")
166
- # Redimension (≈320 px)
167
- h, w = img.shape[:2]
168
- if w > 320:
169
- new_w = 320
170
- new_h = int(h * (320.0 / w)) or 1
171
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
172
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
173
  return out
174
-
175
- def _poster(video: Path) -> Path:
176
- out = THUMB_DIR / f"poster_{video.stem}.jpg"
177
- if out.exists():
178
- return out
 
 
 
 
 
 
 
 
179
  try:
180
- cap = cv2.VideoCapture(str(video))
181
- cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
182
- ok, img = cap.read()
183
- cap.release()
184
- if ok and img is not None:
185
- cv2.imwrite(str(out), img)
186
- except Exception as e:
187
- print(f"[POSTER] Failed: {e}", file=sys.stdout)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  return out
189
-
 
 
 
 
 
 
 
 
 
190
  def _mask_file(vid: str) -> Path:
191
- return MASK_DIR / f"{Path(vid).name}.json"
192
-
193
  def _load_masks(vid: str) -> Dict[str, Any]:
194
- f = _mask_file(vid)
195
- if f.exists():
196
- try:
197
- return json.loads(f.read_text(encoding="utf-8"))
198
- except Exception as e:
199
- print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
200
- return {"video": vid, "masks": []}
201
-
202
- def _save_masks(vid: str, data: Dict[str, Any]):
203
- _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
204
-
205
- def _gen_thumbs_background(video: Path, vid_stem: str):
206
- """
207
- Génère toutes les vignettes en arrière-plan :
208
- - Si FFmpeg dispo : ultra rapide (décode en continu, écrit f_<stem>_%d.jpg)
209
- - Sinon : OpenCV optimisé (lecture séquentielle, redimensionnement CPU)
210
- """
211
- progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
212
  try:
213
- m = _meta(video)
214
- if not m:
215
- progress_data[vid_stem]['logs'].append("Erreur métadonnées")
216
- progress_data[vid_stem]['done'] = True
217
- return
218
- total_frames = int(m["frames"] or 0)
219
- if total_frames <= 0:
220
- progress_data[vid_stem]['logs'].append("Aucune frame détectée")
221
- progress_data[vid_stem]['done'] = True
222
- return
223
-
224
- # Nettoyer d’anciennes thumbs du même stem
225
- for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
226
- f.unlink(missing_ok=True)
227
-
228
- if _has_ffmpeg():
229
- out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
230
- cmd = [
231
- "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
232
- "-i", str(video),
233
- "-vf", _ffmpeg_scale_filter(320),
234
- "-q:v", "8",
235
- "-start_number", "0",
236
- out_tpl
237
- ]
238
- progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
239
- proc = subprocess.Popen(cmd)
240
-
241
- last_report = -1
242
- while proc.poll() is None:
243
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
244
- percent = int(min(99, (generated / max(1, total_frames)) * 100))
245
- progress_data[vid_stem]['percent'] = percent
246
- if generated != last_report and generated % 50 == 0:
247
- progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
248
- last_report = generated
249
- time.sleep(0.4)
250
-
251
- proc.wait()
252
- generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
253
- progress_data[vid_stem]['percent'] = 100
254
- progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
255
- progress_data[vid_stem]['done'] = True
256
- print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
257
-
258
- else:
259
- progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
260
- cap = cv2.VideoCapture(str(video))
261
- if not cap.isOpened():
262
- progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
263
- progress_data[vid_stem]['done'] = True
264
- return
265
- idx = 0
266
- last_report = -1
267
- while True:
268
- ok, img = cap.read()
269
- if not ok or img is None:
270
- break
271
- out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
272
- # Redimension léger (≈320 px de large)
273
- h, w = img.shape[:2]
274
- if w > 320:
275
- new_w = 320
276
- new_h = int(h * (320.0 / w)) or 1
277
- img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
278
- cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
279
- idx += 1
280
- if idx % 50 == 0:
281
- progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
282
- if idx != last_report:
283
- progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
284
- last_report = idx
285
- cap.release()
286
- progress_data[vid_stem]['percent'] = 100
287
- progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
288
- progress_data[vid_stem]['done'] = True
289
- print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
290
-
291
  except Exception as e:
292
- progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  progress_data[vid_stem]['done'] = True
294
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  # ---------- API ----------
296
  @app.get("/", tags=["meta"])
297
  def root():
298
- return {
299
- "ok": True,
300
- "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui"]
301
- }
302
-
303
  @app.get("/health", tags=["meta"])
304
  def health():
305
- return {"status": "ok"}
306
-
 
 
307
  @app.get("/files", tags=["io"])
308
  def files():
309
- items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
310
- return {"count": len(items), "items": items}
311
-
312
  @app.get("/meta/{vid}", tags=["io"])
313
  def video_meta(vid: str):
314
- v = DATA_DIR / vid
315
- if not v.exists():
316
- raise HTTPException(404, "Vidéo introuvable")
317
- m = _meta(v)
318
- if not m:
319
- raise HTTPException(500, "Métadonnées indisponibles")
320
- return m
321
-
322
  @app.post("/upload", tags=["io"])
323
  async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
324
- ext = (Path(file.filename).suffix or ".mp4").lower()
325
- if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
326
- raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm")
327
- base = _safe_name(file.filename)
328
- dst = DATA_DIR / base
329
- if dst.exists():
330
- dst = DATA_DIR / f"{Path(base).stem}__{uuid.uuid4().hex[:8]}{ext}"
331
- with dst.open("wb") as f:
332
- shutil.copyfileobj(file.file, f)
333
- print(f"[UPLOAD] Saved {dst.name} ({dst.stat().st_size} bytes)", file=sys.stdout)
334
- _poster(dst)
335
- stem = dst.stem
336
- threading.Thread(target=_gen_thumbs_background, args=(dst, stem), daemon=True).start()
337
- accept = (request.headers.get("accept") or "").lower()
338
- if redirect or "text/html" in accept:
339
- msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
340
- return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
341
- return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
342
-
343
  @app.get("/progress/{vid_stem}", tags=["io"])
344
  def progress(vid_stem: str):
345
- return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
346
-
347
  @app.delete("/delete/{vid}", tags=["io"])
348
  def delete_video(vid: str):
349
- v = DATA_DIR / vid
350
- if not v.exists():
351
- raise HTTPException(404, "Vidéo introuvable")
352
- (THUMB_DIR / f"poster_{v.stem}.jpg").unlink(missing_ok=True)
353
- for f in THUMB_DIR.glob(f"f_{v.stem}_*.jpg"):
354
- f.unlink(missing_ok=True)
355
- _mask_file(vid).unlink(missing_ok=True)
356
- v.unlink(missing_ok=True)
357
- print(f"[DELETE] {vid}", file=sys.stdout)
358
- return {"deleted": vid}
359
-
360
  @app.get("/frame_idx", tags=["io"])
361
  def frame_idx(vid: str, idx: int):
362
- v = DATA_DIR / vid
363
- if not v.exists():
364
- raise HTTPException(404, "Vidéo introuvable")
365
- try:
366
- out = _frame_jpg(v, int(idx))
367
- print(f"[FRAME] OK {vid} idx={idx}", file=sys.stdout)
368
- return FileResponse(str(out), media_type="image/jpeg")
369
- except HTTPException as he:
370
- print(f"[FRAME] FAIL {vid} idx={idx}: {he.detail}", file=sys.stdout)
371
- raise
372
- except Exception as e:
373
- print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
374
- raise HTTPException(500, "Frame error")
375
-
376
  @app.get("/poster/{vid}", tags=["io"])
377
  def poster(vid: str):
378
- v = DATA_DIR / vid
379
- if not v.exists():
380
- raise HTTPException(404, "Vidéo introuvable")
381
- p = _poster(v)
382
- if p.exists():
383
- return FileResponse(str(p), media_type="image/jpeg")
384
- raise HTTPException(404, "Poster introuvable")
385
-
386
  @app.get("/window/{vid}", tags=["io"])
387
  def window(vid: str, center: int = 0, count: int = 21):
388
- v = DATA_DIR / vid
389
- if not v.exists():
390
- raise HTTPException(404, "Vidéo introuvable")
391
- m = _meta(v)
392
- if not m:
393
- raise HTTPException(500, "Métadonnées indisponibles")
394
- frames = m["frames"]
395
- count = max(3, int(count))
396
- center = max(0, min(int(center), max(0, frames-1)))
397
- if frames <= 0:
398
- print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
399
- return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
400
- if frames <= count:
401
- start = 0; sel = center; n = frames
402
- else:
403
- start = max(0, min(center - (count//2), frames - count))
404
- n = count; sel = center - start
405
- items = []
406
- bust = int(time.time()*1000)
407
- for i in range(n):
408
- idx = start + i
409
- url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
410
- items.append({"i": i, "idx": idx, "url": url})
411
- print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
412
- return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
413
-
414
  # ----- Masques -----
415
  @app.post("/mask", tags=["mask"])
416
  async def save_mask(payload: Dict[str, Any] = Body(...)):
417
- vid = payload.get("vid")
418
- if not vid:
419
- raise HTTPException(400, "vid manquant")
420
- pts = payload.get("points") or []
421
- if len(pts) != 4:
422
- raise HTTPException(400, "points rect (x1,y1,x2,y2) requis")
423
- data = _load_masks(vid)
424
- m = {
425
- "id": uuid.uuid4().hex[:10],
426
- "time_s": float(payload.get("time_s") or 0.0),
427
- "frame_idx": int(payload.get("frame_idx") or 0),
428
- "shape": "rect",
429
- "points": [float(x) for x in pts],
430
- "color": payload.get("color") or "#10b981",
431
- "note": payload.get("note") or ""
432
- }
433
- data.setdefault("masks", []).append(m)
434
- _save_masks(vid, data)
435
- print(f"[MASK] save {vid} frame={m['frame_idx']} note={m['note']}", file=sys.stdout)
436
- return {"saved": True, "mask": m}
437
-
438
  @app.get("/mask/{vid}", tags=["mask"])
439
  def list_masks(vid: str):
440
- return _load_masks(vid)
441
-
442
  @app.post("/mask/rename", tags=["mask"])
443
  async def rename_mask(payload: Dict[str, Any] = Body(...)):
444
- vid = payload.get("vid")
445
- mid = payload.get("id")
446
- new_note = (payload.get("note") or "").strip()
447
- if not vid or not mid:
448
- raise HTTPException(400, "vid et id requis")
449
- data = _load_masks(vid)
450
- for m in data.get("masks", []):
451
- if m.get("id") == mid:
452
- m["note"] = new_note
453
- _save_masks(vid, data)
454
- return {"ok": True}
455
- raise HTTPException(404, "Masque introuvable")
456
-
457
  @app.post("/mask/delete", tags=["mask"])
458
  async def delete_mask(payload: Dict[str, Any] = Body(...)):
459
- vid = payload.get("vid")
460
- mid = payload.get("id")
461
- if not vid or not mid:
462
- raise HTTPException(400, "vid et id requis")
463
- data = _load_masks(vid)
464
- data["masks"] = [m for m in data.get("masks", []) if m.get("id") != mid]
465
- _save_masks(vid, data)
466
- return {"ok": True}
467
-
468
  # ---------- UI ----------
469
  HTML_TEMPLATE = r"""
470
  <!doctype html>
@@ -499,7 +455,7 @@ HTML_TEMPLATE = r"""
499
  .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
500
  .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
501
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
502
- ul.clean{list-style:none;padding-left:0;margin:6px 0}
503
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
504
  .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:#64748b;transition:color 0.2s}
505
  .rename-btn:hover{color:#2563eb}
@@ -523,7 +479,7 @@ HTML_TEMPLATE = r"""
523
  </style>
524
  <h1>🎬 Video Editor</h1>
525
  <div class="topbar card">
526
- <form action="/p/upload?redirect=1" method="post" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center" id="uploadForm">
527
  <strong>Charger une vidéo :</strong>
528
  <input type="file" name="file" accept="video/*" required>
529
  <button class="btn" type="submit">Uploader</button>
@@ -593,8 +549,8 @@ HTML_TEMPLATE = r"""
593
  <div style="margin-top:6px">
594
  <strong>Vidéos disponibles</strong>
595
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
 
596
  </div>
597
- </div>
598
  </div>
599
  <div id="popup">
600
  <h3>Génération thumbs en cours</h3>
@@ -605,7 +561,6 @@ HTML_TEMPLATE = r"""
605
  <script>
606
  const API_BASE = '';
607
  const P = (p) => p;
608
-
609
  const serverVid = "__VID__";
610
  const serverMsg = "__MSG__";
611
  document.getElementById('msg').textContent = serverMsg;
@@ -643,7 +598,6 @@ const hud = document.getElementById('hud');
643
  const toastWrap = document.getElementById('toast');
644
  const gotoInput = document.getElementById('gotoInput');
645
  const gotoBtn = document.getElementById('gotoBtn');
646
-
647
  // State
648
  let vidName = serverVid || '';
649
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
@@ -672,14 +626,13 @@ let lastCenterMs = 0;
672
  const CENTER_THROTTLE_MS = 150;
673
  const PENDING_KEY = 've_pending_masks_v1';
674
  let maskedOnlyMode = false;
675
-
676
  // Utils
677
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
678
  function ensureOverlays(){
679
- if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
680
- if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
681
- if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
682
- if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
683
  }
684
  function playheadEl(){ return document.getElementById('playhead'); }
685
  function portionBand(){ return document.getElementById('portionBand'); }
@@ -688,513 +641,492 @@ function outHandle(){ return document.getElementById('outHandle'); }
688
  function findThumbEl(idx){ return thumbEls.get(idx) || null; }
689
  function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
690
  function updateSelectedThumb(){
691
- tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
692
- const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
693
- img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
694
  }
695
  function rawCenterThumb(el){
696
- tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
697
  }
698
  async function ensureThumbVisibleCentered(idx){
699
- for(let k=0; k<40; k++){
700
- const el = findThumbEl(idx);
701
- if(el){
702
- const img = el.querySelector('img');
703
- if(!img.complete || img.naturalWidth === 0){
704
- await new Promise(r=>setTimeout(r, 25));
705
- }else{
706
- rawCenterThumb(el);
707
- updatePlayhead();
708
- return true;
709
- }
710
- }else{
711
- await new Promise(r=>setTimeout(r, 25));
712
- }
713
- }
714
- return false;
715
  }
716
  function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
717
  function updatePlayhead(){
718
- ensureOverlays();
719
- const el = findThumbEl(currentIdx);
720
- const ph = playheadEl();
721
- if(!el){ ph.style.display='none'; return; }
722
- ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
723
  }
724
  function updatePortionOverlays(){
725
- ensureOverlays();
726
- const pb = portionBand(), ih = inHandle(), oh = outHandle();
727
- if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
728
- const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
729
- if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
730
- const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
731
- pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
732
- ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
733
- oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
734
  }
735
  function nearestFrameIdxFromClientX(clientX){
736
- const rect = tlBox.getBoundingClientRect();
737
- const xIn = clientX - rect.left + tlBox.scrollLeft;
738
- let bestIdx = currentIdx, bestDist = Infinity;
739
- for(const [idx, el] of thumbEls.entries()){
740
- const mid = el.offsetLeft + el.clientWidth/2;
741
- const d = Math.abs(mid - xIn);
742
- if(d < bestDist){ bestDist = d; bestIdx = idx; }
743
- }
744
- return bestIdx;
745
  }
746
  function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
747
  function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
748
  function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
749
  async function flushPending(){
750
- const lst = loadPending(); if(!lst.length) return;
751
- const kept = [];
752
- for(const p of lst){
753
- try{ const r = await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
754
- catch{ kept.push(p); }
755
- }
756
- savePendingList(kept);
757
- if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
758
  }
759
-
760
  // Layout
761
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
762
  function fitCanvas(){
763
- const r=player.getBoundingClientRect();
764
- const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
765
- canvas.width=Math.round(r.width);
766
- canvas.height=Math.round(r.height - ctrlH);
767
- canvas.style.width=r.width+'px';
768
- canvas.style.height=(r.height - ctrlH)+'px';
769
- syncTimelineWidth();
770
  }
771
  function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
772
  function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
773
-
774
  function setMode(m){
775
- mode=m;
776
- if(m==='edit'){
777
- player.pause();
778
- player.controls = false;
779
- playerWrap.classList.add('edit-mode');
780
- modeLabel.textContent='Édition';
781
- btnEdit.style.display='none'; btnBack.style.display='inline-block';
782
- btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
783
- canvas.style.pointerEvents='auto';
784
- rect = rectMap.get(currentIdx) || null; draw();
785
- }else{
786
- player.controls = true;
787
- playerWrap.classList.remove('edit-mode');
788
- modeLabel.textContent='Lecture';
789
- btnEdit.style.display='inline-block'; btnBack.style.display='none';
790
- btnSave.style.display='none'; btnClear.style.display='none';
791
- canvas.style.pointerEvents='none';
792
- rect=null; draw();
793
- }
794
  }
795
  function draw(){
796
- ctx.clearRect(0,0,canvas.width,canvas.height);
797
- if(rect){
798
- const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
799
- const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
800
- ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
801
- ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
802
- }
803
  }
804
  canvas.addEventListener('mousedown',(e)=>{
805
- if(mode!=='edit' || !vidName) return;
806
- dragging=true; const r=canvas.getBoundingClientRect();
807
- sx=e.clientX-r.left; sy=e.clientY-r.top;
808
- rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
809
  });
810
  canvas.addEventListener('mousemove',(e)=>{
811
- if(!dragging) return;
812
- const r=canvas.getBoundingClientRect();
813
- rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
814
  });
815
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
816
  btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
817
  btnEdit.onclick =()=> setMode('edit');
818
  btnBack.onclick =()=> setMode('view');
819
-
820
  // Palette
821
  palette.querySelectorAll('.swatch').forEach(el=>{
822
- if(el.dataset.c===color) el.classList.add('sel');
823
- el.onclick=()=>{
824
- palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
825
- el.classList.add('sel'); color=el.dataset.c;
826
- if(rect){ rect.color=color; draw(); }
827
- };
828
  });
829
-
830
  // === Timeline ===
831
  async function loadTimelineUrls(){
832
- timelineUrls = [];
833
- const stem = vidStem, b = bustToken;
834
- for(let idx=0; idx<frames; idx++){
835
- timelineUrls[idx] = P(`/thumbs/f_${stem}_${idx}.jpg?b=${b}`);
836
- }
837
- tlProgressFill.style.width='0%';
838
  }
839
-
840
  async function renderTimeline(centerIdx){
841
- if(!vidName) return;
842
- loadingInd.style.display='block';
843
- if(timelineUrls.length===0) await loadTimelineUrls();
844
- tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
845
-
846
- if(maskedOnlyMode){
847
- const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
848
- for(const i of idxs){ addThumb(i,'append'); }
849
- setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
850
- return;
851
- }
852
-
853
- if(portionStart!=null && portionEnd!=null){
854
- const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
855
- for(let i=s;i<e;i++){ addThumb(i,'append'); }
856
- setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
857
- return;
858
- }
859
-
860
- await loadWindow(centerIdx ?? currentIdx);
861
- loadingInd.style.display='none';
862
  }
863
-
864
  async function loadWindow(centerIdx){
865
- tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
866
- const rngStart = (viewRangeStart ?? 0);
867
- const rngEnd = (viewRangeEnd ?? frames);
868
- const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
869
- const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
870
- const end = Math.min(rngEnd, start + chunkSize);
871
- for(let i=start;i<end;i++){ addThumb(i,'append'); }
872
- timelineStart = start; timelineEnd = end;
873
- setTimeout(async ()=>{
874
- syncTimelineWidth();
875
- updateSelectedThumb();
876
- await ensureThumbVisibleCentered(currentIdx);
877
- updatePortionOverlays();
878
- },0);
879
  }
880
-
881
  function addThumb(idx, place='append'){
882
- if(thumbEls.has(idx)) return;
883
- const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
884
- if(maskedSet.has(idx)) wrap.classList.add('hasmask');
885
- const img=new Image(); img.title='frame '+(idx+1);
886
- img.src=timelineUrls[idx];
887
- img.onerror = () => {
888
- const fallback = P(`/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`);
889
- img.onerror = null;
890
- img.src = fallback;
891
- img.onload = () => {
892
- const nu = P(`/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`);
893
- timelineUrls[idx] = nu;
894
- img.src = nu;
895
- img.onload = null;
896
- };
897
- };
898
- if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
899
- img.onclick=async ()=>{
900
- currentIdx=idx; player.currentTime=idxToSec(currentIdx);
901
- if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
902
- updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
903
- };
904
- wrap.appendChild(img);
905
- const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
906
- wrap.appendChild(label);
907
- if(place==='append'){ tlBox.appendChild(wrap); }
908
- else if(place==='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
909
- else{ tlBox.appendChild(wrap); }
910
- thumbEls.set(idx, wrap);
911
  }
912
-
913
  // Scroll chunk (mode normal uniquement)
914
  tlBox.addEventListener('scroll', ()=>{
915
- if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
916
- updatePlayhead(); updatePortionOverlays();
917
- return;
918
- }
919
- const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
920
- if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
921
- const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
922
- for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
923
- timelineEnd = newEnd;
924
- }
925
- if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
926
- const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
927
- for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
928
- tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
929
- timelineStart = newStart;
930
- }
931
- updatePlayhead(); updatePortionOverlays();
932
  });
933
-
934
  // Isoler & Boucle
935
  isolerBoucle.onclick = async ()=>{
936
- const start = parseInt(goFrame.value || '1',10) - 1;
937
- const end = parseInt(endPortion.value || '',10);
938
- if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
939
- if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
940
- portionStart = start; portionEnd = end;
941
- viewRangeStart = start; viewRangeEnd = end;
942
- player.pause(); isPaused = true;
943
- currentIdx = start; player.currentTime = idxToSec(start);
944
- await renderTimeline(currentIdx);
945
- resetFull.style.display = 'inline-block';
946
- startLoop(); updatePortionOverlays();
947
  };
948
  function startLoop(){
949
- if(loopInterval) clearInterval(loopInterval);
950
- if(portionEnd != null){
951
- loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
952
- }
953
  }
954
  resetFull.onclick = async ()=>{
955
- portionStart = null; portionEnd = null;
956
- viewRangeStart = 0; viewRangeEnd = frames;
957
- goFrame.value = 1; endPortion.value = '';
958
- player.pause(); isPaused = true;
959
- await renderTimeline(currentIdx);
960
- resetFull.style.display='none';
961
- clearInterval(loopInterval); updatePortionOverlays();
962
  };
963
-
964
  // Drag IN/OUT
965
  function attachHandleDrag(handle, which){
966
- let draggingH=false;
967
- function onMove(e){
968
- if(!draggingH) return;
969
- const idx = nearestFrameIdxFromClientX(e.clientX);
970
- if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
971
- else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
972
- viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
973
- updatePortionOverlays();
974
- }
975
- handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
976
- window.addEventListener('mousemove', onMove);
977
- window.addEventListener('mouseup', ()=>{ draggingH=false; });
978
  }
979
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
980
-
981
  // Progress popup
982
  async function showProgress(vidStem){
983
- popup.style.display = 'block';
984
- const interval = setInterval(async () => {
985
- const r = await fetch(P('/progress/' + vidStem));
986
- const d = await r.json();
987
- tlProgressFill.style.width = d.percent + '%';
988
- popupProgressFill.style.width = d.percent + '%';
989
- popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
990
- if(d.done){
991
- clearInterval(interval);
992
- popup.style.display = 'none';
993
- await renderTimeline(currentIdx);
994
- }
995
- }, 800);
996
  }
997
-
998
  // Meta & boot
999
  async function loadVideoAndMeta() {
1000
- if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
1001
- vidStem = fileStem(vidName); bustToken = Date.now();
1002
- const bust = Date.now();
1003
- srcEl.src = P('/data/'+encodeURIComponent(vidName)+'?t='+bust);
1004
- player.setAttribute('poster', P('/poster/'+encodeURIComponent(vidName)+'?t='+bust));
1005
- player.load();
1006
- fitCanvas();
1007
- statusEl.textContent = 'Chargement vidéo…';
1008
- try{
1009
- const r=await fetch(P('/meta/'+encodeURIComponent(vidName)));
1010
- if(r.ok){
1011
- const m=await r.json();
1012
- fps=m.fps||30; frames=m.frames||0;
1013
- statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
1014
- viewRangeStart = 0; viewRangeEnd = frames;
1015
- await loadTimelineUrls();
1016
- await loadMasks();
1017
- currentIdx = 0; player.currentTime = 0;
1018
- await renderTimeline(0);
1019
- showProgress(vidStem);
1020
- }else{
1021
- statusEl.textContent = 'Erreur meta';
1022
- }
1023
- }catch(err){
1024
- statusEl.textContent = 'Erreur réseau meta';
1025
- }
1026
  }
1027
  player.addEventListener('loadedmetadata', async ()=>{
1028
- fitCanvas();
1029
- if(!frames || frames<=0){
1030
- try{ const r=await fetch(P('/meta/'+encodeURIComponent(vidName))); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
1031
- }
1032
- currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
1033
  });
1034
  window.addEventListener('resize', ()=>{ fitCanvas(); });
1035
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
1036
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
1037
  player.addEventListener('timeupdate', ()=>{
1038
- posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
1039
- currentIdx=timeToIdx(player.currentTime);
1040
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1041
- updateHUD(); updateSelectedThumb(); updatePlayhead();
1042
- if(followMode && !isPaused){
1043
- const now = Date.now();
1044
- if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
1045
- }
1046
  });
1047
  goFrame.addEventListener('change', async ()=>{
1048
- if(!vidName) return;
1049
- const val=Math.max(1, parseInt(goFrame.value||'1',10));
1050
- player.pause(); isPaused = true;
1051
- currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
1052
- if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
1053
- await renderTimeline(currentIdx);
1054
- await ensureThumbVisibleCentered(currentIdx);
1055
  });
1056
-
1057
  // Follow / Filter / Zoom / Goto
1058
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
1059
  btnFilterMasked.onclick = async ()=>{
1060
- maskedOnlyMode = !maskedOnlyMode;
1061
- btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
1062
- tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1063
- await renderTimeline(currentIdx);
1064
- await ensureThumbVisibleCentered(currentIdx);
1065
  };
1066
  zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1067
  async function gotoFrameNum(){
1068
- const v = parseInt(gotoInput.value||'',10);
1069
- if(!Number.isFinite(v) || v<1 || v>frames) return;
1070
- player.pause(); isPaused = true;
1071
- currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1072
- goFrame.value = v;
1073
- await renderTimeline(currentIdx);
1074
- await ensureThumbVisibleCentered(currentIdx);
1075
  }
1076
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1077
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
1078
-
1079
  // Drag & drop upload
1080
  const uploadZone = document.getElementById('uploadForm');
1081
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1082
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1083
  uploadZone.addEventListener('drop', (e) => {
1084
- e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1085
- const file = e.dataTransfer.files[0];
1086
- if(file && file.type.startsWith('video/')){
1087
- const fd = new FormData(); fd.append('file', file);
1088
- fetch(P('/upload?redirect=1'), {method: 'POST', body: fd}).then(() => location.reload());
1089
- }
1090
  });
1091
  // Export placeholder
1092
  document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
1093
-
1094
  // Fichiers & masques
1095
  async function loadFiles(){
1096
- const r=await fetch(P('/files')); const d=await r.json();
1097
- if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1098
- fileList.innerHTML='';
1099
- d.items.forEach(name=>{
1100
- const li=document.createElement('li');
1101
- const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1102
- delBtn.onclick=async()=>{
1103
- if(!confirm(`Supprimer "${name}" ?`)) return;
1104
- await fetch(P('/delete/'+encodeURIComponent(name)),{method:'DELETE'});
1105
- loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1106
- };
1107
- const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1108
- li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1109
- });
1110
  }
1111
  async function loadMasks(){
1112
- loadingInd.style.display='block';
1113
- const box=document.getElementById('maskList');
1114
- const r=await fetch(P('/mask/'+encodeURIComponent(vidName)));
1115
- const d=await r.json();
1116
- masks=d.masks||[];
1117
- maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1118
- rectMap.clear();
1119
- masks.forEach(m=>{
1120
- if(m.shape==='rect'){
1121
- const [x1,y1,x2,y2] = m.points;
1122
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
1123
- rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1124
- }
1125
- });
1126
- maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1127
- if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1128
- box.innerHTML='';
1129
- const ul=document.createElement('ul'); ul.className='clean';
1130
- masks.forEach(m=>{
1131
- const li=document.createElement('li');
1132
- const fr=(parseInt(m.frame_idx||0,10)+1);
1133
- const t=(m.time_s||0).toFixed(2);
1134
- const col=m.color||'#10b981';
1135
- const label=m.note||(`frame ${fr}`);
1136
- 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,'&lt;').replace(/>/g,'&gt;')}</strong> — #${fr} · t=${t}s`;
1137
- const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1138
- renameBtn.onclick=async()=>{
1139
- const nv=prompt('Nouveau nom du masque :', label);
1140
- if(nv===null) return;
1141
- const rr=await fetch(P('/mask/rename'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1142
- if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1143
- const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1144
- }
1145
- };
1146
- const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1147
- delMaskBtn.onclick=async()=>{
1148
- if(!confirm(`Supprimer masque "${label}" ?`)) return;
1149
- const rr=await fetch(P('/mask/delete'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1150
- if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1151
- const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1152
- }
1153
- };
1154
- li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1155
- });
1156
- box.appendChild(ul);
1157
- loadingInd.style.display='none';
1158
  }
1159
-
1160
  // Save mask (+ cache)
1161
  btnSave.onclick = async ()=>{
1162
- if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1163
- const defaultName = `frame ${currentIdx+1}`;
1164
- const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1165
- const normW = canvas.clientWidth, normH = canvas.clientHeight;
1166
- const x=Math.min(rect.x1,rect.x2)/normW;
1167
- const y=Math.min(rect.y1,rect.y2)/normH;
1168
- const w=Math.abs(rect.x2-rect.x1)/normW;
1169
- const h=Math.abs(rect.y2-rect.y1)/normH;
1170
- const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:rect.color||color,note:note};
1171
- addPending(payload);
1172
- try{
1173
- const r=await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1174
- if(r.ok){
1175
- const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1176
- savePendingList(lst);
1177
- rectMap.set(currentIdx,{...rect});
1178
- await loadMasks(); await renderTimeline(currentIdx);
1179
- showToast('Masque enregistré ✅');
1180
- } else {
1181
- const txt = await r.text();
1182
- alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1183
- }
1184
- }catch(e){
1185
- alert('Erreur réseau lors de l’enregistrement du masque.');
1186
- }
1187
  };
1188
-
1189
  // Boot
1190
  async function boot(){
1191
- const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1192
- await loadFiles();
1193
- if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1194
- else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1195
  }
1196
  boot();
1197
-
1198
  // Hide controls in edit-mode
1199
  const style = document.createElement('style');
1200
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
@@ -1202,13 +1134,12 @@ document.head.appendChild(style);
1202
  </script>
1203
  </html>
1204
  """
1205
-
1206
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1207
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1208
- vid = v or ""
1209
- try:
1210
- msg = urllib.parse.unquote(msg or "")
1211
- except Exception:
1212
- pass
1213
- html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1214
- return HTMLResponse(content=html)
 
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 = HTMLResponse, FileResponse, RedirectResponse
8
+ from fastapi.staticfiles = StaticFiles
9
  from pathlib import Path
10
  from typing import Optional, Dict, Any
11
  import uuid, shutil, cv2, json, time, urllib.parse, sys
12
  import threading
13
  import subprocess
14
  import shutil as _shutil
 
15
  # --- POINTEUR DE BACKEND (lit l'URL actuelle depuis une source externe) ------
16
  import os
17
  import httpx
 
18
  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)'}")
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"
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", "/_env"] # Ajout /_env
268
+ }
 
269
  @app.get("/health", tags=["meta"])
270
  def health():
271
+ return {"status": "ok"}
272
+ @app.get("/_env", tags=["meta"]) # Ajout debug
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>
 
455
  .btn.active,.btn.toggled{background:var(--active-bg);border-color:var(--active-border)}
456
  .swatch{width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ccc;cursor:pointer;transition:box-shadow 0.2s}
457
  .swatch.sel{box-shadow:0 0 0 2px var(--active-border)}
458
+ ul.clean{list-style:none;padding-left=0;margin:6px 0}
459
  ul.clean li{margin:2px 0;display:flex;align-items:center;gap:6px}
460
  .rename-btn{font-size:12px;padding:2px 4px;border:none;background:transparent;cursor:pointer;color:#64748b;transition:color 0.2s}
461
  .rename-btn:hover{color:#2563eb}
 
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>
 
549
  <div style="margin-top:6px">
550
  <strong>Vidéos disponibles</strong>
551
  <ul id="fileList" class="clean muted" style="max-height:180px;overflow:auto">Chargement…</ul>
552
+ </div>
553
  </div>
 
554
  </div>
555
  <div id="popup">
556
  <h3>Génération thumbs en cours</h3>
 
561
  <script>
562
  const API_BASE = '';
563
  const P = (p) => p;
 
564
  const serverVid = "__VID__";
565
  const serverMsg = "__MSG__";
566
  document.getElementById('msg').textContent = serverMsg;
 
598
  const toastWrap = document.getElementById('toast');
599
  const gotoInput = document.getElementById('gotoInput');
600
  const gotoBtn = document.getElementById('gotoBtn');
 
601
  // State
602
  let vidName = serverVid || '';
603
  function fileStem(name){ const i = name.lastIndexOf('.'); return i>0 ? name.slice(0,i) : name; }
 
626
  const CENTER_THROTTLE_MS = 150;
627
  const PENDING_KEY = 've_pending_masks_v1';
628
  let maskedOnlyMode = false;
 
629
  // Utils
630
  function showToast(msg){ const el = document.createElement('div'); el.className='toast-item'; el.textContent=String(msg||''); toastWrap.appendChild(el); setTimeout(()=>{ el.remove(); }, 2000); }
631
  function ensureOverlays(){
632
+ if(!document.getElementById('playhead')){ const ph=document.createElement('div'); ph.id='playhead'; ph.className='playhead'; tlBox.appendChild(ph); }
633
+ if(!document.getElementById('portionBand')){ const pb=document.createElement('div'); pb.id='portionBand'; tlBox.appendChild(pb); }
634
+ if(!document.getElementById('inHandle')){ const ih=document.createElement('div'); ih.id='inHandle'; tlBox.appendChild(ih); }
635
+ if(!document.getElementById('outHandle')){ const oh=document.createElement('div'); oh.id='outHandle'; tlBox.appendChild(oh); }
636
  }
637
  function playheadEl(){ return document.getElementById('playhead'); }
638
  function portionBand(){ return document.getElementById('portionBand'); }
 
641
  function findThumbEl(idx){ return thumbEls.get(idx) || null; }
642
  function updateHUD(){ const total = frames || 0, cur = currentIdx+1; hud.textContent = `t=${player.currentTime.toFixed(2)}s • #${cur}/${total} • ${fps.toFixed(2)}fps`; }
643
  function updateSelectedThumb(){
644
+ tlBox.querySelectorAll('.thumb img.sel, .thumb img.sel-strong').forEach(img=>{ img.classList.remove('sel','sel-strong'); });
645
+ const el = findThumbEl(currentIdx); if(!el) return; const img = el.querySelector('img');
646
+ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong');
647
  }
648
  function rawCenterThumb(el){
649
+ tlBox.scrollLeft = Math.max(0, el.offsetLeft + el.clientWidth/2 - tlBox.clientWidth/2);
650
  }
651
  async function ensureThumbVisibleCentered(idx){
652
+ for(let k=0; k<40; k++){
653
+ const el = findThumbEl(idx);
654
+ if(el){
655
+ const img = el.querySelector('img');
656
+ if(!img.complete || img.naturalWidth === 0){
657
+ await new Promise(r=>setTimeout(r, 25));
658
+ }else{
659
+ rawCenterThumb(el);
660
+ updatePlayhead();
661
+ return true;
662
+ }
663
+ }else{
664
+ await new Promise(r=>setTimeout(r, 25));
665
+ }
666
+ }
667
+ return false;
668
  }
669
  function centerSelectedThumb(){ ensureThumbVisibleCentered(currentIdx); }
670
  function updatePlayhead(){
671
+ ensureOverlays();
672
+ const el = findThumbEl(currentIdx);
673
+ const ph = playheadEl();
674
+ if(!el){ ph.style.display='none'; return; }
675
+ ph.style.display='block'; ph.style.left = (el.offsetLeft + el.clientWidth/2) + 'px';
676
  }
677
  function updatePortionOverlays(){
678
+ ensureOverlays();
679
+ const pb = portionBand(), ih = inHandle(), oh = outHandle();
680
+ if(portionStart==null || portionEnd==null){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
681
+ const a = findThumbEl(portionStart), b = findThumbEl(portionEnd-1);
682
+ if(!a || !b){ pb.style.display='none'; ih.style.display='none'; oh.style.display='none'; return; }
683
+ const left = a.offsetLeft, right = b.offsetLeft + b.clientWidth;
684
+ pb.style.display='block'; pb.style.left = left+'px'; pb.style.width = Math.max(0, right-left)+'px';
685
+ ih.style.display='block'; ih.style.left = (left - ih.clientWidth/2) + 'px';
686
+ oh.style.display='block'; oh.style.left = (right - oh.clientWidth/2) + 'px';
687
  }
688
  function nearestFrameIdxFromClientX(clientX){
689
+ const rect = tlBox.getBoundingClientRect();
690
+ const xIn = clientX - rect.left + tlBox.scrollLeft;
691
+ let bestIdx = currentIdx, bestDist = Infinity;
692
+ for(const [idx, el] of thumbEls.entries()){
693
+ const mid = el.offsetLeft + el.clientWidth/2;
694
+ const d = Math.abs(mid - xIn);
695
+ if(d < bestDist){ bestDist = d; bestIdx = idx; }
696
+ }
697
+ return bestIdx;
698
  }
699
  function loadPending(){ try{ return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); }catch{ return []; } }
700
  function savePendingList(lst){ localStorage.setItem(PENDING_KEY, JSON.stringify(lst)); }
701
  function addPending(payload){ const lst = loadPending(); lst.push(payload); savePendingList(lst); }
702
  async function flushPending(){
703
+ const lst = loadPending(); if(!lst.length) return;
704
+ const kept = [];
705
+ for(const p of lst){
706
+ try{ const r = await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)}); if(!r.ok) kept.push(p); }
707
+ catch{ kept.push(p); }
708
+ }
709
+ savePendingList(kept);
710
+ if(kept.length===0) showToast('Masques hors-ligne synchronisés ✅');
711
  }
 
712
  // Layout
713
  function syncTimelineWidth(){ const w = playerWrap.clientWidth; tlBox.style.width = w + 'px'; }
714
  function fitCanvas(){
715
+ const r=player.getBoundingClientRect();
716
+ const ctrlH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--controlsH'));
717
+ canvas.width=Math.round(r.width);
718
+ canvas.height=Math.round(r.height - ctrlH);
719
+ canvas.style.width=r.width+'px';
720
+ canvas.style.height=(r.height - ctrlH)+'px';
721
+ syncTimelineWidth();
722
  }
723
  function timeToIdx(t){ return Math.max(0, Math.min(frames-1, Math.round((fps||30) * t))); }
724
  function idxToSec(i){ return (fps||30)>0 ? (i / fps) : 0; }
 
725
  function setMode(m){
726
+ mode=m;
727
+ if(m==='edit'){
728
+ player.pause();
729
+ player.controls = false;
730
+ playerWrap.classList.add('edit-mode');
731
+ modeLabel.textContent='Édition';
732
+ btnEdit.style.display='none'; btnBack.style.display='inline-block';
733
+ btnSave.style.display='inline-block'; btnClear.style.display='inline-block';
734
+ canvas.style.pointerEvents='auto';
735
+ rect = rectMap.get(currentIdx) || null; draw();
736
+ }else{
737
+ player.controls = true;
738
+ playerWrap.classList.remove('edit-mode');
739
+ modeLabel.textContent='Lecture';
740
+ btnEdit.style.display='inline-block'; btnBack.style.display='none';
741
+ btnSave.style.display='none'; btnClear.style.display='none';
742
+ canvas.style.pointerEvents='none';
743
+ rect=null; draw();
744
+ }
745
  }
746
  function draw(){
747
+ ctx.clearRect(0,0,canvas.width,canvas.height);
748
+ if(rect){
749
+ const x=Math.min(rect.x1,rect.x2), y=Math.min(rect.y1,rect.y2);
750
+ const w=Math.abs(rect.x2-rect.x1), h=Math.abs(rect.y2-rect.y1);
751
+ ctx.strokeStyle=rect.color||color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
752
+ ctx.fillStyle=(rect.color||color)+'28'; ctx.fillRect(x,y,w,h);
753
+ }
754
  }
755
  canvas.addEventListener('mousedown',(e)=>{
756
+ if(mode!=='edit' || !vidName) return;
757
+ dragging=true; const r=canvas.getBoundingClientRect();
758
+ sx=e.clientX-r.left; sy=e.clientY-r.top;
759
+ rect={x1:sx,y1:sy,x2:sx,y2:sy,color:color}; draw();
760
  });
761
  canvas.addEventListener('mousemove',(e)=>{
762
+ if(!dragging) return;
763
+ const r=canvas.getBoundingClientRect();
764
+ rect.x2=e.clientX-r.left; rect.y2=e.clientY-r.top; draw();
765
  });
766
  ['mouseup','mouseleave'].forEach(ev=>canvas.addEventListener(ev,()=>{ dragging=false; }));
767
  btnClear.onclick=()=>{ rect=null; rectMap.delete(currentIdx); draw(); };
768
  btnEdit.onclick =()=> setMode('edit');
769
  btnBack.onclick =()=> setMode('view');
 
770
  // Palette
771
  palette.querySelectorAll('.swatch').forEach(el=>{
772
+ if(el.dataset.c===color) el.classList.add('sel');
773
+ el.onclick=()=>{
774
+ palette.querySelectorAll('.swatch').forEach(s=>s.classList.remove('sel'));
775
+ el.classList.add('sel'); color=el.dataset.c;
776
+ if(rect){ rect.color=color; draw(); }
777
+ };
778
  });
 
779
  // === Timeline ===
780
  async function loadTimelineUrls(){
781
+ timelineUrls = [];
782
+ const stem = vidStem, b = bustToken;
783
+ for(let idx=0; idx<frames; idx++){
784
+ timelineUrls[idx] = P(`/thumbs/f_${stem}_${idx}.jpg?b=${b}`);
785
+ }
786
+ tlProgressFill.style.width='0%';
787
  }
 
788
  async function renderTimeline(centerIdx){
789
+ if(!vidName) return;
790
+ loadingInd.style.display='block';
791
+ if(timelineUrls.length===0) await loadTimelineUrls();
792
+ tlBox.innerHTML = ''; thumbEls = new Map(); ensureOverlays();
793
+ if(maskedOnlyMode){
794
+ const idxs = Array.from(maskedSet).sort((a,b)=>a-b);
795
+ for(const i of idxs){ addThumb(i,'append'); }
796
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
797
+ return;
798
+ }
799
+ if(portionStart!=null && portionEnd!=null){
800
+ const s = Math.max(0, portionStart), e = Math.min(frames, portionEnd);
801
+ for(let i=s;i<e;i++){ addThumb(i,'append'); }
802
+ setTimeout(async ()=>{ syncTimelineWidth(); updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); loadingInd.style.display='none'; updatePlayhead(); updatePortionOverlays(); },0);
803
+ return;
804
+ }
805
+ await loadWindow(centerIdx ?? currentIdx);
806
+ loadingInd.style.display='none';
 
 
 
807
  }
 
808
  async function loadWindow(centerIdx){
809
+ tlBox.innerHTML=''; thumbEls = new Map(); ensureOverlays();
810
+ const rngStart = (viewRangeStart ?? 0);
811
+ const rngEnd = (viewRangeEnd ?? frames);
812
+ const mid = Math.max(rngStart, Math.min(centerIdx, Math.max(rngStart, rngEnd-1)));
813
+ const start = Math.max(rngStart, Math.min(mid - Math.floor(chunkSize/2), Math.max(rngStart, rngEnd - chunkSize)));
814
+ const end = Math.min(rngEnd, start + chunkSize);
815
+ for(let i=start;i<end;i++){ addThumb(i,'append'); }
816
+ timelineStart = start; timelineEnd = end;
817
+ setTimeout(async ()=>{
818
+ syncTimelineWidth();
819
+ updateSelectedThumb();
820
+ await ensureThumbVisibleCentered(currentIdx);
821
+ updatePortionOverlays();
822
+ },0);
823
  }
 
824
  function addThumb(idx, place='append'){
825
+ if(thumbEls.has(idx)) return;
826
+ const wrap=document.createElement('div'); wrap.className='thumb'; wrap.dataset.idx=idx;
827
+ if(maskedSet.has(idx)) wrap.classList.add('hasmask');
828
+ const img=new Image(); img.title='frame '+(idx+1);
829
+ img.src=timelineUrls[idx];
830
+ img.onerror = () => {
831
+ const fallback = P(`/frame_idx?vid=${encodeURIComponent(vidName)}&idx=${idx}`);
832
+ img.onerror = null;
833
+ img.src = fallback;
834
+ img.onload = () => {
835
+ const nu = P(`/thumbs/f_${vidStem}_${idx}.jpg?b=${Date.now()}`);
836
+ timelineUrls[idx] = nu;
837
+ img.src = nu;
838
+ img.onload = null;
839
+ };
840
+ };
841
+ if(idx===currentIdx){ img.classList.add('sel'); if(isPaused) img.classList.add('sel-strong'); }
842
+ img.onclick=async ()=>{
843
+ currentIdx=idx; player.currentTime=idxToSec(currentIdx);
844
+ if(mode==='edit'){ rect = rectMap.get(currentIdx)||null; draw(); }
845
+ updateSelectedThumb(); await ensureThumbVisibleCentered(currentIdx); updatePlayhead();
846
+ };
847
+ wrap.appendChild(img);
848
+ const label=document.createElement('span'); label.className='thumb-label'; label.textContent = `#${idx+1}`;
849
+ wrap.appendChild(label);
850
+ if(place==='append'){ tlBox.appendChild(wrap); }
851
+ else if(place='prepend'){ tlBox.insertBefore(wrap, tlBox.firstChild); }
852
+ else{ tlBox.appendChild(wrap); }
853
+ thumbEls.set(idx, wrap);
854
  }
 
855
  // Scroll chunk (mode normal uniquement)
856
  tlBox.addEventListener('scroll', ()=>{
857
+ if (maskedOnlyMode || (portionStart!=null && portionEnd!=null)){
858
+ updatePlayhead(); updatePortionOverlays();
859
+ return;
860
+ }
861
+ const scrollLeft = tlBox.scrollLeft, scrollWidth = tlBox.scrollWidth, clientWidth = tlBox.clientWidth;
862
+ if (scrollWidth - scrollLeft - clientWidth < scrollThreshold && timelineEnd < viewRangeEnd){
863
+ const newEnd = Math.min(viewRangeEnd, timelineEnd + chunkSize);
864
+ for(let i=timelineEnd;i<newEnd;i++){ addThumb(i,'append'); }
865
+ timelineEnd = newEnd;
866
+ }
867
+ if (scrollLeft < scrollThreshold && timelineStart > viewRangeStart){
868
+ const newStart = Math.max(viewRangeStart, timelineStart - chunkSize);
869
+ for(let i=newStart;i<timelineStart;i++){ addThumb(i,'prepend'); }
870
+ tlBox.scrollLeft += (timelineStart - newStart) * (110 + 8);
871
+ timelineStart = newStart;
872
+ }
873
+ updatePlayhead(); updatePortionOverlays();
874
  });
 
875
  // Isoler & Boucle
876
  isolerBoucle.onclick = async ()=>{
877
+ const start = parseInt(goFrame.value || '1',10) - 1;
878
+ const end = parseInt(endPortion.value || '',10);
879
+ if(!endPortion.value || end <= start || end > frames){ alert('Portion invalide (fin > début)'); return; }
880
+ if (end - start > 1200 && !confirm('Portion très large, cela peut être lent. Continuer ?')) return;
881
+ portionStart = start; portionEnd = end;
882
+ viewRangeStart = start; viewRangeEnd = end;
883
+ player.pause(); isPaused = true;
884
+ currentIdx = start; player.currentTime = idxToSec(start);
885
+ await renderTimeline(currentIdx);
886
+ resetFull.style.display = 'inline-block';
887
+ startLoop(); updatePortionOverlays();
888
  };
889
  function startLoop(){
890
+ if(loopInterval) clearInterval(loopInterval);
891
+ if(portionEnd != null){
892
+ loopInterval = setInterval(()=>{ if(player.currentTime >= idxToSec(portionEnd)) player.currentTime = idxToSec(portionStart); }, 100);
893
+ }
894
  }
895
  resetFull.onclick = async ()=>{
896
+ portionStart = null; portionEnd = null;
897
+ viewRangeStart = 0; viewRangeEnd = frames;
898
+ goFrame.value = 1; endPortion.value = '';
899
+ player.pause(); isPaused = true;
900
+ await renderTimeline(currentIdx);
901
+ resetFull.style.display='none';
902
+ clearInterval(loopInterval); updatePortionOverlays();
903
  };
 
904
  // Drag IN/OUT
905
  function attachHandleDrag(handle, which){
906
+ let draggingH=false;
907
+ function onMove(e){
908
+ if(!draggingH) return;
909
+ const idx = nearestFrameIdxFromClientX(e.clientX);
910
+ if(which==='in'){ portionStart = Math.min(idx, portionEnd ?? idx+1); goFrame.value = (portionStart+1); }
911
+ else { portionEnd = Math.max(idx+1, (portionStart ?? idx)); endPortion.value = portionEnd; }
912
+ viewRangeStart = (portionStart ?? 0); viewRangeEnd = (portionEnd ?? frames);
913
+ updatePortionOverlays();
914
+ }
915
+ handle.addEventListener('mousedown', (e)=>{ draggingH=true; e.preventDefault(); });
916
+ window.addEventListener('mousemove', onMove);
917
+ window.addEventListener('mouseup', ()=>{ draggingH=false; });
918
  }
919
  ensureOverlays(); attachHandleDrag(document.getElementById('inHandle'), 'in'); attachHandleDrag(document.getElementById('outHandle'), 'out');
 
920
  // Progress popup
921
  async function showProgress(vidStem){
922
+ popup.style.display = 'block';
923
+ const interval = setInterval(async () => {
924
+ const r = await fetch(P('/progress/' + vidStem));
925
+ const d = await r.json();
926
+ tlProgressFill.style.width = d.percent + '%';
927
+ popupProgressFill.style.width = d.percent + '%';
928
+ popupLogs.innerHTML = d.logs.map(x=>String(x)).join('<br>');
929
+ if(d.done){
930
+ clearInterval(interval);
931
+ popup.style.display = 'none';
932
+ await renderTimeline(currentIdx);
933
+ }
934
+ }, 800);
935
  }
 
936
  // Meta & boot
937
  async function loadVideoAndMeta() {
938
+ if(!vidName){ statusEl.textContent='Aucune vidéo sélectionnée.'; return; }
939
+ vidStem = fileStem(vidName); bustToken = Date.now();
940
+ const bust = Date.now();
941
+ srcEl.src = P('/data/'+encodeURIComponent(vidName)+'?t='+bust);
942
+ player.setAttribute('poster', P('/poster/'+encodeURIComponent(vidName)+'?t='+bust));
943
+ player.load();
944
+ fitCanvas();
945
+ statusEl.textContent = 'Chargement vidéo…';
946
+ try{
947
+ const r=await fetch(P('/meta/'+encodeURIComponent(vidName)));
948
+ if(r.ok){
949
+ const m=await r.json();
950
+ fps=m.fps||30; frames=m.frames||0;
951
+ statusEl.textContent = `OK (${frames} frames @ ${fps.toFixed(2)} fps)`;
952
+ viewRangeStart = 0; viewRangeEnd = frames;
953
+ await loadTimelineUrls();
954
+ await loadMasks();
955
+ currentIdx = 0; player.currentTime = 0;
956
+ await renderTimeline(0);
957
+ showProgress(vidStem);
958
+ }else{
959
+ statusEl.textContent = 'Erreur meta';
960
+ }
961
+ }catch(err){
962
+ statusEl.textContent = 'Erreur réseau meta';
963
+ }
964
  }
965
  player.addEventListener('loadedmetadata', async ()=>{
966
+ fitCanvas();
967
+ if(!frames || frames<=0){
968
+ try{ const r=await fetch(P('/meta/'+encodeURIComponent(vidName))); if(r.ok){ const m=await r.json(); fps=m.fps||30; frames=m.frames||0; } }catch{}
969
+ }
970
+ currentIdx=0; goFrame.value=1; rectMap.clear(); rect=null; draw();
971
  });
972
  window.addEventListener('resize', ()=>{ fitCanvas(); });
973
  player.addEventListener('pause', ()=>{ isPaused = true; updateSelectedThumb(); centerSelectedThumb(); });
974
  player.addEventListener('play', ()=>{ isPaused = false; updateSelectedThumb(); });
975
  player.addEventListener('timeupdate', ()=>{
976
+ posInfo.textContent='t='+player.currentTime.toFixed(2)+'s';
977
+ currentIdx=timeToIdx(player.currentTime);
978
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
979
+ updateHUD(); updateSelectedThumb(); updatePlayhead();
980
+ if(followMode && !isPaused){
981
+ const now = Date.now();
982
+ if(now - lastCenterMs > CENTER_THROTTLE_MS){ lastCenterMs = now; centerSelectedThumb(); }
983
+ }
984
  });
985
  goFrame.addEventListener('change', async ()=>{
986
+ if(!vidName) return;
987
+ const val=Math.max(1, parseInt(goFrame.value||'1',10));
988
+ player.pause(); isPaused = true;
989
+ currentIdx=val-1; player.currentTime=idxToSec(currentIdx);
990
+ if(mode==='edit'){ rect = rectMap.get(currentIdx) || null; draw(); }
991
+ await renderTimeline(currentIdx);
992
+ await ensureThumbVisibleCentered(currentIdx);
993
  });
 
994
  // Follow / Filter / Zoom / Goto
995
  btnFollow.onclick = ()=>{ followMode = !followMode; btnFollow.classList.toggle('toggled', followMode); if(followMode) centerSelectedThumb(); };
996
  btnFilterMasked.onclick = async ()=>{
997
+ maskedOnlyMode = !maskedOnlyMode;
998
+ btnFilterMasked.classList.toggle('toggled', maskedOnlyMode);
999
+ tlBox.classList.toggle('filter-masked', maskedOnlyMode);
1000
+ await renderTimeline(currentIdx);
1001
+ await ensureThumbVisibleCentered(currentIdx);
1002
  };
1003
  zoomSlider.addEventListener('input', ()=>{ tlBox.style.setProperty('--thumbH', zoomSlider.value + 'px'); });
1004
  async function gotoFrameNum(){
1005
+ const v = parseInt(gotoInput.value||'',10);
1006
+ if(!Number.isFinite(v) || v<1 || v>frames) return;
1007
+ player.pause(); isPaused = true;
1008
+ currentIdx = v-1; player.currentTime = idxToSec(currentIdx);
1009
+ goFrame.value = v;
1010
+ await renderTimeline(currentIdx);
1011
+ await ensureThumbVisibleCentered(currentIdx);
1012
  }
1013
  gotoBtn.onclick = ()=>{ gotoFrameNum(); };
1014
  gotoInput.addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); gotoFrameNum(); } });
 
1015
  // Drag & drop upload
1016
  const uploadZone = document.getElementById('uploadForm');
1017
  uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.style.borderColor = '#2563eb'; });
1018
  uploadZone.addEventListener('dragleave', () => { uploadZone.style.borderColor = 'transparent'; });
1019
  uploadZone.addEventListener('drop', (e) => {
1020
+ e.preventDefault(); uploadZone.style.borderColor = 'transparent';
1021
+ const file = e.dataTransfer.files[0];
1022
+ if(file && file.type.startsWith('video/')){
1023
+ const fd = new FormData(); fd.append('file', file);
1024
+ fetch(P('/upload?redirect=1'), {method: 'POST', body: fd}).then(() => location.reload());
1025
+ }
1026
  });
1027
  // Export placeholder
1028
  document.getElementById('exportBtn').onclick = () => { console.log('Export en cours... (IA à venir)'); alert('Fonctionnalité export IA en développement !'); };
 
1029
  // Fichiers & masques
1030
  async function loadFiles(){
1031
+ const r=await fetch(P('/files')); const d=await r.json();
1032
+ if(!d.items || !d.items.length){ fileList.innerHTML='<li>(aucune)</li>'; return; }
1033
+ fileList.innerHTML='';
1034
+ d.items.forEach(name=>{
1035
+ const li=document.createElement('li');
1036
+ const delBtn = document.createElement('span'); delBtn.className='delete-btn'; delBtn.textContent='❌'; delBtn.title='Supprimer cette vidéo';
1037
+ delBtn.onclick=async=>{
1038
+ if(!confirm(`Supprimer "${name}" ?`)) return;
1039
+ await fetch(P('/delete/'+encodeURIComponent(name)),{method:'DELETE'});
1040
+ loadFiles(); if(vidName===name){ vidName=''; loadVideoAndMeta(); }
1041
+ };
1042
+ const a=document.createElement('a'); a.textContent=name; a.href='/ui?v='+encodeURIComponent(name); a.title='Ouvrir cette vidéo';
1043
+ li.appendChild(delBtn); li.appendChild(a); fileList.appendChild(li);
1044
+ });
1045
  }
1046
  async function loadMasks(){
1047
+ loadingInd.style.display='block';
1048
+ const box=document.getElementById('maskList');
1049
+ const r=await fetch(P('/mask/'+encodeURIComponent(vidName)));
1050
+ const d=await r.json();
1051
+ masks=d.masks||[];
1052
+ maskedSet = new Set(masks.map(m=>parseInt(m.frame_idx||0,10)));
1053
+ rectMap.clear();
1054
+ masks.forEach(m=>{
1055
+ if(m.shape==='rect'){
1056
+ const [x1,y1,x2,y2] = m.points;
1057
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1058
+ rectMap.set(m.frame_idx, {x1:x1*normW, y1:y1*normH, x2:x2*normW, y2:y2*normH, color:m.color});
1059
+ }
1060
+ });
1061
+ maskedCount.textContent = `(${maskedSet.size} ⭐)`;
1062
+ if(!masks.length){ box.textContent='—'; loadingInd.style.display='none'; return; }
1063
+ box.innerHTML='';
1064
+ const ul=document.createElement('ul'); ul.className='clean';
1065
+ masks.forEach(m=>{
1066
+ const li=document.createElement('li');
1067
+ const fr=(parseInt(m.frame_idx||0,10)+1);
1068
+ const t=(m.time_s||0).toFixed(2);
1069
+ const col=m.color||'#10b981';
1070
+ const label=m.note||(`frame ${fr}`);
1071
+ 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,'&lt;').replace(/>/g,'&gt;')}</strong> — #${fr} · t=${t}s`;
1072
+ const renameBtn=document.createElement('span'); renameBtn.className='rename-btn'; renameBtn.textContent='✏️'; renameBtn.title='Renommer le masque';
1073
+ renameBtn.onclick=async=>{
1074
+ const nv=prompt('Nouveau nom du masque :', label);
1075
+ if(nv===null) return;
1076
+ const rr=await fetch(P('/mask/rename'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id,note:nv})});
1077
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque renommé ✅'); } else {
1078
+ const txt = await rr.text(); alert('Échec renommage: ' + rr.status + ' ' + txt);
1079
+ }
1080
+ };
1081
+ const delMaskBtn=document.createElement('span'); delMaskBtn.className='delete-btn'; delMaskBtn.textContent='❌'; delMaskBtn.title='Supprimer ce masque';
1082
+ delMaskBtn.onclick=async=>{
1083
+ if(!confirm(`Supprimer masque "${label}" ?`)) return;
1084
+ const rr=await fetch(P('/mask/delete'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({vid:vidName,id:m.id})});
1085
+ if(rr.ok){ await loadMasks(); await renderTimeline(currentIdx); draw(); showToast('Masque supprimé ✅'); } else {
1086
+ const txt = await rr.text(); alert('Échec suppression: ' + rr.status + ' ' + txt);
1087
+ }
1088
+ };
1089
+ li.appendChild(renameBtn); li.appendChild(delMaskBtn); ul.appendChild(li);
1090
+ });
1091
+ box.appendChild(ul);
1092
+ loadingInd.style.display='none';
1093
  }
 
1094
  // Save mask (+ cache)
1095
  btnSave.onclick = async ()=>{
1096
+ if(!rect || !vidName){ alert('Aucune sélection.'); return; }
1097
+ const defaultName = `frame ${currentIdx+1}`;
1098
+ const note = (prompt('Nom du masque (optionnel) :', defaultName) || defaultName).trim();
1099
+ const normW = canvas.clientWidth, normH = canvas.clientHeight;
1100
+ const x=Math.min(rect.x1,rect.x2)/normW;
1101
+ const y=Math.min(rect.y1,rect.y2)/normH;
1102
+ const w=Math.abs(rect.x2-rect.x1)/normW;
1103
+ const h=Math.abs(rect.y2-rect.y1)/normH;
1104
+ const payload={vid:vidName,time_s:player.currentTime,frame_idx:currentIdx,shape:'rect',points:[x,y,x+w,y+h],color:rect.color||color,note:note};
1105
+ addPending(payload);
1106
+ try{
1107
+ const r=await fetch(P('/mask'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1108
+ if(r.ok){
1109
+ const lst = loadPending().filter(x => !(x.vid===payload.vid && x.frame_idx===payload.frame_idx && x.time_s===payload.time_s));
1110
+ savePendingList(lst);
1111
+ rectMap.set(currentIdx,{...rect});
1112
+ await loadMasks(); await renderTimeline(currentIdx);
1113
+ showToast('Masque enregistré ✅');
1114
+ } else {
1115
+ const txt = await r.text();
1116
+ alert('Échec enregistrement masque: ' + r.status + ' ' + txt);
1117
+ }
1118
+ }catch(e){
1119
+ alert('Erreur réseau lors de l’enregistrement du masque.');
1120
+ }
1121
  };
 
1122
  // Boot
1123
  async function boot(){
1124
+ const lst = JSON.parse(localStorage.getItem('ve_pending_masks_v1') || '[]'); if(lst.length){ /* flush pending si besoin */ }
1125
+ await loadFiles();
1126
+ if(serverVid){ vidName=serverVid; await loadVideoAndMeta(); }
1127
+ else{ const qs=new URLSearchParams(location.search); const v=qs.get('v')||''; if(v){ vidName=v; await loadVideoAndMeta(); } }
1128
  }
1129
  boot();
 
1130
  // Hide controls in edit-mode
1131
  const style = document.createElement('style');
1132
  style.textContent = `.player-wrap.edit-mode video::-webkit-media-controls { display: none !important; } .player-wrap.edit-mode video::before { content: none !important; }`;
 
1134
  </script>
1135
  </html>
1136
  """
 
1137
  @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
1138
  def ui(v: Optional[str] = "", msg: Optional[str] = ""):
1139
+ vid = v or ""
1140
+ try:
1141
+ msg = urllib.parse.unquote(msg or "")
1142
+ except Exception:
1143
+ pass
1144
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
1145
+ return HTMLResponse(content=html)