FABLESLIP commited on
Commit
68e5297
·
verified ·
1 Parent(s): 1932190

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +429 -0
app.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — Video Editor API (v0.5.10)
2
+ # v0.5.10:
3
+ # - Accepte deux jeux d'ENV: (BACKEND_POINTER_URL/BACKEND_BASE_URL) OU (POINTER_URL/FALLBACK_BASE)
4
+ # - Ajout /_ping et /_env pour diagnostic rapide (sans mots interdits)
5
+ # - Reste identique côté API/UI
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
+ # Supporte tes anciens noms d'ENV ET les nouveaux :
22
+ POINTER_URL = (
23
+ os.getenv("BACKEND_POINTER_URL")
24
+ or os.getenv("POINTER_URL")
25
+ or ""
26
+ ).strip()
27
+
28
+ FALLBACK_BASE = (
29
+ os.getenv("BACKEND_BASE_URL")
30
+ or os.getenv("FALLBACK_BASE")
31
+ or "http://127.0.0.1:8765"
32
+ ).strip()
33
+
34
+ _backend_url_cache = {"url": None, "ts": 0.0}
35
+
36
+ def get_backend_base() -> str:
37
+ """
38
+ Renvoie l'URL du backend.
39
+ - Si un pointeur d'URL est défini (fichier texte externe contenant l’URL publique courante),
40
+ on lit le contenu et on le met en cache 30 s.
41
+ - Sinon on utilise FALLBACK_BASE.
42
+ """
43
+ try:
44
+ if POINTER_URL:
45
+ now = time.time()
46
+ need_refresh = (not _backend_url_cache["url"] or now - _backend_url_cache["ts"] > 30)
47
+ if need_refresh:
48
+ r = httpx.get(POINTER_URL, timeout=5)
49
+ url = (r.text or "").strip()
50
+ if url.startswith("http"):
51
+ _backend_url_cache["url"] = url
52
+ _backend_url_cache["ts"] = now
53
+ else:
54
+ return FALLBACK_BASE
55
+ return _backend_url_cache["url"] or FALLBACK_BASE
56
+ return FALLBACK_BASE
57
+ except Exception:
58
+ return FALLBACK_BASE
59
+ # ---------------------------------------------------------------------------
60
+ print("[BOOT] Video Editor API starting…")
61
+ print(f"[BOOT] POINTER_URL={'(set)' if POINTER_URL else '(unset)'}")
62
+ print(f"[BOOT] FALLBACK_BASE={FALLBACK_BASE}")
63
+
64
+ app = FastAPI(title="Video Editor API", version="0.5.10")
65
+
66
+ DATA_DIR = Path("/app/data")
67
+ THUMB_DIR = DATA_DIR / "_thumbs"
68
+ MASK_DIR = DATA_DIR / "_masks"
69
+ for p in (DATA_DIR, THUMB_DIR, MASK_DIR):
70
+ p.mkdir(parents=True, exist_ok=True)
71
+
72
+ app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
73
+ app.mount("/thumbs", StaticFiles(directory=str(THUMB_DIR)), name="thumbs")
74
+
75
+ # --- PROXY (pas de CORS côté navigateur) -------------------------------------
76
+ @app.api_route("/p/{full_path:path}", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
77
+ async def proxy_all(full_path: str, request: Request):
78
+ base = get_backend_base().rstrip("/")
79
+ target = f"{base}/{full_path}"
80
+ qs = request.url.query
81
+ if qs:
82
+ target = f"{target}?{qs}"
83
+
84
+ body = await request.body()
85
+ headers = dict(request.headers)
86
+ headers.pop("host", None)
87
+
88
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
89
+ r = await client.request(request.method, target, headers=headers, content=body)
90
+
91
+ drop = {"content-encoding","transfer-encoding","connection",
92
+ "keep-alive","proxy-authenticate","proxy-authorization",
93
+ "te","trailers","upgrade"}
94
+ out_headers = {k:v for k,v in r.headers.items() if k.lower() not in drop}
95
+ return Response(content=r.content, status_code=r.status_code, headers=out_headers)
96
+ # -------------------------------------------------------------------------------
97
+
98
+ # Global progress dict (vid_stem -> {percent, logs, done})
99
+ progress_data: Dict[str, Dict[str, Any]] = {}
100
+
101
+ # ---------- Helpers ----------
102
+ def _is_video(p: Path) -> bool:
103
+ return p.suffix.lower() in {".mp4", ".mov", ".mkv", ".webm"}
104
+
105
+ def _safe_name(name: str) -> str:
106
+ return Path(name).name.replace(" ", "_")
107
+
108
+ def _has_ffmpeg() -> bool:
109
+ return _shutil.which("ffmpeg") is not None
110
+
111
+ def _ffmpeg_scale_filter(max_w: int = 320) -> str:
112
+ # Utilisation en subprocess (pas shell), on échappe la virgule.
113
+ return f"scale=min(iw\\,{max_w}):-2"
114
+
115
+ def _meta(video: Path):
116
+ cap = cv2.VideoCapture(str(video))
117
+ if not cap.isOpened():
118
+ print(f"[META] OpenCV cannot open: {video}", file=sys.stdout)
119
+ return None
120
+ frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
121
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) or 30.0
122
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
123
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
124
+ cap.release()
125
+ print(f"[META] {video.name} -> frames={frames}, fps={fps:.3f}, size={w}x{h}", file=sys.stdout)
126
+ return {"frames": frames, "fps": fps, "w": w, "h": h}
127
+
128
+ def _frame_jpg(video: Path, idx: int) -> Path:
129
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
130
+ if out.exists():
131
+ return out
132
+ if _has_ffmpeg():
133
+ m = _meta(video) or {"fps": 30.0}
134
+ fps = float(m.get("fps") or 30.0) or 30.0
135
+ t = max(0.0, float(idx) / fps)
136
+ cmd = [
137
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
138
+ "-ss", f"{t:.6f}",
139
+ "-i", str(video),
140
+ "-frames:v", "1",
141
+ "-vf", _ffmpeg_scale_filter(320),
142
+ "-q:v", "8",
143
+ str(out)
144
+ ]
145
+ try:
146
+ subprocess.run(cmd, check=True)
147
+ return out
148
+ except subprocess.CalledProcessError as e:
149
+ print(f"[FRAME:FFMPEG] seek fail t={t:.4f} idx={idx}: {e}", file=sys.stdout)
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
+ h, w = img.shape[:2]
167
+ if w > 320:
168
+ new_w = 320
169
+ new_h = int(h * (320.0 / w)) or 1
170
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
171
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
172
+ return out
173
+
174
+ def _poster(video: Path) -> Path:
175
+ out = THUMB_DIR / f"poster_{video.stem}.jpg"
176
+ if out.exists():
177
+ return out
178
+ try:
179
+ cap = cv2.VideoCapture(str(video))
180
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
181
+ ok, img = cap.read()
182
+ cap.release()
183
+ if ok and img is not None:
184
+ cv2.imwrite(str(out), img)
185
+ except Exception as e:
186
+ print(f"[POSTER] Failed: {e}", file=sys.stdout)
187
+ return out
188
+
189
+ def _mask_file(vid: str) -> Path:
190
+ return MASK_DIR / f"{Path(vid).name}.json"
191
+
192
+ def _load_masks(vid: str) -> Dict[str, Any]:
193
+ f = _mask_file(vid)
194
+ if f.exists():
195
+ try:
196
+ return json.loads(f.read_text(encoding="utf-8"))
197
+ except Exception as e:
198
+ print(f"[MASK] Read fail {vid}: {e}", file=sys.stdout)
199
+ return {"video": vid, "masks": []}
200
+
201
+ def _save_masks(vid: str, data: Dict[str, Any]):
202
+ _mask_file(vid).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
203
+
204
+ def _gen_thumbs_background(video: Path, vid_stem: str):
205
+ progress_data[vid_stem] = {'percent': 0, 'logs': [], 'done': False}
206
+ try:
207
+ m = _meta(video)
208
+ if not m:
209
+ progress_data[vid_stem]['logs'].append("Erreur métadonnées")
210
+ progress_data[vid_stem]['done'] = True
211
+ return
212
+ total_frames = int(m["frames"] or 0)
213
+ if total_frames <= 0:
214
+ progress_data[vid_stem]['logs'].append("Aucune frame détectée")
215
+ progress_data[vid_stem]['done'] = True
216
+ return
217
+ for f in THUMB_DIR.glob(f"f_{video.stem}_*.jpg"):
218
+ f.unlink(missing_ok=True)
219
+
220
+ if _has_ffmpeg():
221
+ out_tpl = str(THUMB_DIR / f"f_{video.stem}_%d.jpg")
222
+ cmd = [
223
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
224
+ "-i", str(video),
225
+ "-vf", _ffmpeg_scale_filter(320),
226
+ "-q:v", "8",
227
+ "-start_number", "0",
228
+ out_tpl
229
+ ]
230
+ progress_data[vid_stem]['logs'].append("FFmpeg: génération en cours…")
231
+ proc = subprocess.Popen(cmd)
232
+ last_report = -1
233
+ while proc.poll() is None:
234
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
235
+ percent = int(min(99, (generated / max(1, total_frames)) * 100))
236
+ progress_data[vid_stem]['percent'] = percent
237
+ if generated != last_report and generated % 50 == 0:
238
+ progress_data[vid_stem]['logs'].append(f"Gen {generated}/{total_frames}")
239
+ last_report = generated
240
+ time.sleep(0.4)
241
+ proc.wait()
242
+ generated = len(list(THUMB_DIR.glob(f"f_{video.stem}_*.jpg")))
243
+ progress_data[vid_stem]['percent'] = 100
244
+ progress_data[vid_stem]['logs'].append("OK FFmpeg: {}/{} thumbs".format(generated, total_frames))
245
+ progress_data[vid_stem]['done'] = True
246
+ print(f"[PRE-GEN:FFMPEG] {generated} thumbs for {video.name}", file=sys.stdout)
247
+ else:
248
+ progress_data[vid_stem]['logs'].append("OpenCV (FFmpeg non dispo) : génération…")
249
+ cap = cv2.VideoCapture(str(video))
250
+ if not cap.isOpened():
251
+ progress_data[vid_stem]['logs'].append("OpenCV ne peut pas ouvrir la vidéo.")
252
+ progress_data[vid_stem]['done'] = True
253
+ return
254
+ idx = 0
255
+ last_report = -1
256
+ while True:
257
+ ok, img = cap.read()
258
+ if not ok or img is None:
259
+ break
260
+ out = THUMB_DIR / f"f_{video.stem}_{idx}.jpg"
261
+ h, w = img.shape[:2]
262
+ if w > 320:
263
+ new_w = 320
264
+ new_h = int(h * (320.0 / w)) or 1
265
+ img = cv2.resize(img, (new_w, new_h), interpolation=getattr(cv2, 'INTER_AREA', cv2.INTER_LINEAR))
266
+ cv2.imwrite(str(out), img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
267
+ idx += 1
268
+ if idx % 50 == 0:
269
+ progress_data[vid_stem]['percent'] = int(min(99, (idx / max(1, total_frames)) * 100))
270
+ if idx != last_report:
271
+ progress_data[vid_stem]['logs'].append(f"Gen {idx}/{total_frames}")
272
+ last_report = idx
273
+ cap.release()
274
+ progress_data[vid_stem]['percent'] = 100
275
+ progress_data[vid_stem]['logs'].append(f"OK OpenCV: {idx}/{total_frames} thumbs")
276
+ progress_data[vid_stem]['done'] = True
277
+ print(f"[PRE-GEN:CV2] {idx} thumbs for {video.name}", file=sys.stdout)
278
+ except Exception as e:
279
+ progress_data[vid_stem]['logs'].append(f"Erreur: {e}")
280
+ progress_data[vid_stem]['done'] = True
281
+
282
+ # ---------- API ----------
283
+ @app.get("/", tags=["meta"])
284
+ def root():
285
+ return {
286
+ "ok": True,
287
+ "routes": ["/", "/health", "/files", "/upload", "/meta/{vid}", "/frame_idx", "/poster/{vid}", "/window/{vid}", "/mask", "/mask/{vid}", "/mask/rename", "/mask/delete", "/progress/{vid_stem}", "/ui", "/_ping", "/_env"]
288
+ }
289
+
290
+ @app.get("/health", tags=["meta"])
291
+ def health():
292
+ return {"status": "ok"}
293
+
294
+ # Diagnostics simples (pour vérifier conteneur & ENV)
295
+ @app.get("/_ping", tags=["meta"])
296
+ def ping():
297
+ return {"ok": True, "ts": time.time()}
298
+
299
+ @app.get("/_env", tags=["meta"])
300
+ def env_info():
301
+ # On n’expose pas les secrets, juste des infos utiles
302
+ resolved = get_backend_base()
303
+ return {
304
+ "pointer_set": bool(POINTER_URL),
305
+ "pointer_url_length": len(POINTER_URL or ""),
306
+ "fallback_base": FALLBACK_BASE,
307
+ "resolved_base": resolved
308
+ }
309
+
310
+ @app.get("/files", tags=["io"])
311
+ def files():
312
+ items = [p.name for p in sorted(DATA_DIR.glob("*")) if _is_video(p)]
313
+ return {"count": len(items), "items": items}
314
+
315
+ @app.get("/meta/{vid}", tags=["io"])
316
+ def video_meta(vid: str):
317
+ v = DATA_DIR / vid
318
+ if not v.exists():
319
+ raise HTTPException(404, "Vidéo introuvable")
320
+ m = _meta(v)
321
+ if not m:
322
+ raise HTTPException(500, "Métadonnées indisponibles")
323
+ return m
324
+
325
+ @app.post("/upload", tags=["io"])
326
+ async def upload(request: Request, file: UploadFile = File(...), redirect: Optional[bool] = True):
327
+ ext = (Path(file.filename).suffix or ".mp4").lower()
328
+ if ext not in {".mp4", ".mov", ".mkv", ".webm"}:
329
+ raise HTTPException(400, "Formats acceptés : mp4/mov/mkv/webm")
330
+ base = _safe_name(file.filename)
331
+ dst = DATA_DIR / base
332
+ if dst.exists():
333
+ dst = DATA_DIR / f"{Path(base).stem}__{uuid.uuid4().hex[:8]}{ext}"
334
+ with dst.open("wb") as f:
335
+ shutil.copyfileobj(file.file, f)
336
+ print(f"[UPLOAD] Saved {dst.name} ({dst.stat().st_size} bytes)", file=sys.stdout)
337
+ _poster(dst)
338
+ stem = dst.stem
339
+ threading.Thread(target=_gen_thumbs_background, args=(dst, stem), daemon=True).start()
340
+ accept = (request.headers.get("accept") or "").lower()
341
+ if redirect or "text/html" in accept:
342
+ msg = urllib.parse.quote(f"Vidéo importée : {dst.name}. Génération thumbs en cours…")
343
+ return RedirectResponse(url=f"/ui?v={urllib.parse.quote(dst.name)}&msg={msg}", status_code=303)
344
+ return {"name": dst.name, "size_bytes": dst.stat().st_size, "gen_started": True}
345
+
346
+ @app.get("/progress/{vid_stem}", tags=["io"])
347
+ def progress(vid_stem: str):
348
+ return progress_data.get(vid_stem, {'percent': 0, 'logs': [], 'done': False})
349
+
350
+ @app.delete("/delete/{vid}", tags=["io"])
351
+ def delete_video(vid: str):
352
+ v = DATA_DIR / vid
353
+ if not v.exists():
354
+ raise HTTPException(404, "Vidéo introuvable")
355
+ (THUMB_DIR / f"poster_{v.stem}.jpg").unlink(missing_ok=True)
356
+ for f in THUMB_DIR.glob(f"f_{v.stem}_*.jpg"):
357
+ f.unlink(missing_ok=True)
358
+ _mask_file(vid).unlink(missing_ok=True)
359
+ v.unlink(missing_ok=True)
360
+ print(f"[DELETE] {vid}", file=sys.stdout)
361
+ return {"deleted": vid}
362
+
363
+ @app.get("/frame_idx", tags=["io"])
364
+ def frame_idx(vid: str, idx: int):
365
+ v = DATA_DIR / vid
366
+ if not v.exists():
367
+ raise HTTPException(404, "Vidéo introuvable")
368
+ try:
369
+ out = _frame_jpg(v, int(idx))
370
+ print(f"[FRAME] OK {vid} idx={idx}", file=sys.stdout)
371
+ return FileResponse(str(out), media_type="image/jpeg")
372
+ except HTTPException as he:
373
+ print(f"[FRAME] FAIL {vid} idx={idx}: {he.detail}", file=sys.stdout)
374
+ raise
375
+ except Exception as e:
376
+ print(f"[FRAME] FAIL {vid} idx={idx}: {e}", file=sys.stdout)
377
+ raise HTTPException(500, "Frame error")
378
+
379
+ @app.get("/poster/{vid}", tags=["io"])
380
+ def poster(vid: str):
381
+ v = DATA_DIR / vid
382
+ if not v.exists():
383
+ raise HTTPException(404, "Vidéo introuvable")
384
+ p = _poster(v)
385
+ if p.exists():
386
+ return FileResponse(str(p), media_type="image/jpeg")
387
+ raise HTTPException(404, "Poster introuvable")
388
+
389
+ @app.get("/window/{vid}", tags=["io"])
390
+ def window(vid: str, center: int = 0, count: int = 21):
391
+ v = DATA_DIR / vid
392
+ if not v.exists():
393
+ raise HTTPException(404, "Vidéo introuvable")
394
+ m = _meta(v)
395
+ if not m:
396
+ raise HTTPException(500, "Métadonnées indisponibles")
397
+ frames = m["frames"]
398
+ count = max(3, int(count))
399
+ center = max(0, min(int(center), max(0, frames-1)))
400
+ if frames <= 0:
401
+ print(f"[WINDOW] frames=0 for {vid}", file=sys.stdout)
402
+ return {"vid": vid, "start": 0, "count": 0, "selected": 0, "items": [], "frames": 0}
403
+ if frames <= count:
404
+ start = 0; sel = center; n = frames
405
+ else:
406
+ start = max(0, min(center - (count//2), frames - count))
407
+ n = count; sel = center - start
408
+ items = []
409
+ bust = int(time.time()*1000)
410
+ for i in range(n):
411
+ idx = start + i
412
+ url = f"/thumbs/f_{v.stem}_{idx}.jpg?b={bust}"
413
+ items.append({"i": i, "idx": idx, "url": url})
414
+ print(f"[WINDOW] {vid} start={start} n={n} sel={sel} frames={frames}", file=sys.stdout)
415
+ return {"vid": vid, "start": start, "count": n, "selected": sel, "items": items, "frames": frames}
416
+
417
+ # ---------- UI ----------
418
+ HTML_TEMPLATE = r"""(… tout le HTML/JS de ton UI identique à ta version 0.5.9 …)"""
419
+ # Pour gagner de la place ici, garde exactement ton HTML_TEMPLATE 0.5.9 précédent.
420
+
421
+ @app.get("/ui", response_class=HTMLResponse, tags=["meta"])
422
+ def ui(v: Optional[str] = "", msg: Optional[str] = ""):
423
+ vid = v or ""
424
+ try:
425
+ msg = urllib.parse.unquote(msg or "")
426
+ except Exception:
427
+ pass
428
+ html = HTML_TEMPLATE.replace("__VID__", urllib.parse.quote(vid)).replace("__MSG__", msg)
429
+ return HTMLResponse(content=html)