feat(api): serve a thumbnail for each rush
A frame taken just after the start of the rush, generated once with ffmpeg and cached in the project's derush folder.
e615554 parent: c821b56 modified
backend/studio/modules/projects/api.py +13 -0 | @@ -863,6 +863,19 @@ def rush_media(pid: str, rush_id: str): | ||
| 863 | 863 | return FileResponse(rush["path"]) |
| 864 | 864 | |
| 865 | 865 | |
| 866 | +@router.get("/{pid}/rushes/{rush_id}/thumb") | |
| 867 | +def rush_thumb(pid: str, rush_id: str): | |
| 868 | + """La vignette du rush dans la médiathèque : une image prise au début, calculée une fois puis gardée.""" | |
| 869 | + _check(pid) | |
| 870 | + rush = next((r for r in service.load(pid)["rushes"] if r["id"] == rush_id), None) | |
| 871 | + if not rush or not Path(rush["path"]).is_file(): | |
| 872 | + raise HTTPException(404, "rush introuvable") | |
| 873 | + try: | |
| 874 | + return FileResponse(runtime.rush_thumb(pid, rush), headers={"Cache-Control": "max-age=86400"}) | |
| 875 | + except RuntimeError as e: | |
| 876 | + raise HTTPException(500, str(e)) from e | |
| 877 | + | |
| 878 | + | |
| 866 | 879 | @router.get("/{pid}/rushes/{rush_id}/peaks") |
| 867 | 880 | def rush_peaks(pid: str, rush_id: str): |
| 868 | 881 | """La forme d'onde du rush (une crête 0-100 par 1/RUSH_PEAKS_RATE s), calculée une fois puis gardée.""" |
| @@ -863,6 +863,19 @@ def rush_media(pid: str, rush_id: str): | |||
| 863 | return FileResponse(rush["path"]) | 863 | return FileResponse(rush["path"]) |
| 864 | 864 | ||
| 865 | 865 | ||
| 866 | +@router.get("/{pid}/rushes/{rush_id}/thumb") | ||
| 867 | +def rush_thumb(pid: str, rush_id: str): | ||
| 868 | + """La vignette du rush dans la médiathèque : une image prise au début, calculée une fois puis gardée.""" | ||
| 869 | + _check(pid) | ||
| 870 | + rush = next((r for r in service.load(pid)["rushes"] if r["id"] == rush_id), None) | ||
| 871 | + if not rush or not Path(rush["path"]).is_file(): | ||
| 872 | + raise HTTPException(404, "rush introuvable") | ||
| 873 | + try: | ||
| 874 | + return FileResponse(runtime.rush_thumb(pid, rush), headers={"Cache-Control": "max-age=86400"}) | ||
| 875 | + except RuntimeError as e: | ||
| 876 | + raise HTTPException(500, str(e)) from e | ||
| 877 | + | ||
| 878 | + | ||
| 866 | @router.get("/{pid}/rushes/{rush_id}/peaks") | 879 | @router.get("/{pid}/rushes/{rush_id}/peaks") |
| 867 | def rush_peaks(pid: str, rush_id: str): | 880 | def rush_peaks(pid: str, rush_id: str): |
| 868 | """La forme d'onde du rush (une crête 0-100 par 1/RUSH_PEAKS_RATE s), calculée une fois puis gardée.""" | 881 | """La forme d'onde du rush (une crête 0-100 par 1/RUSH_PEAKS_RATE s), calculée une fois puis gardée.""" |
modified
backend/studio/modules/projects/runtime.py +19 -0 | @@ -231,6 +231,25 @@ def rush_levels(pid: str, rush: dict) -> list[int]: | ||
| 231 | 231 | return levels |
| 232 | 232 | |
| 233 | 233 | |
| 234 | +def rush_thumb(pid: str, rush: dict) -> Path: | |
| 235 | + """Une image du rush, un peu après le début (la première est souvent noire ou floue).""" | |
| 236 | + path = Path(rush["path"]) | |
| 237 | + key = hashlib.sha1(f"{path}:{path.stat().st_mtime}".encode()).hexdigest()[:10] | |
| 238 | + dest = project_dir(pid) / service.DERUSH_DIR / f"thumb-{rush['id']}-{key}.jpg" | |
| 239 | + if dest.is_file(): | |
| 240 | + return dest | |
| 241 | + dest.parent.mkdir(parents=True, exist_ok=True) | |
| 242 | + at = min(1.0, (rush.get("duration") or 0) / 3) | |
| 243 | + proc = subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{at:.2f}", "-i", str(path), "-frames:v", "1", | |
| 244 | + "-vf", "scale=320:-2", "-q:v", "5", str(dest)], capture_output=True) | |
| 245 | + if proc.returncode != 0 or not dest.is_file(): | |
| 246 | + raise RuntimeError(f"ffmpeg : {proc.stderr.decode(errors='replace')[-300:]}") | |
| 247 | + for old in dest.parent.glob(f"thumb-{rush['id']}-*.jpg"): | |
| 248 | + if old != dest: | |
| 249 | + old.unlink(missing_ok=True) | |
| 250 | + return dest | |
| 251 | + | |
| 252 | + | |
| 234 | 253 | def rush_peaks(pid: str, rush: dict) -> list[int]: |
| 235 | 254 | """La forme d'onde à afficher (0-100) : rapportée au haut de la voix, pas au pic le plus fort, |
| 236 | 255 | pour qu'un claquement isolé n'écrase pas tout le reste.""" |
| @@ -231,6 +231,25 @@ def rush_levels(pid: str, rush: dict) -> list[int]: | |||
| 231 | return levels | 231 | return levels |
| 232 | 232 | ||
| 233 | 233 | ||
| 234 | +def rush_thumb(pid: str, rush: dict) -> Path: | ||
| 235 | + """Une image du rush, un peu après le début (la première est souvent noire ou floue).""" | ||
| 236 | + path = Path(rush["path"]) | ||
| 237 | + key = hashlib.sha1(f"{path}:{path.stat().st_mtime}".encode()).hexdigest()[:10] | ||
| 238 | + dest = project_dir(pid) / service.DERUSH_DIR / f"thumb-{rush['id']}-{key}.jpg" | ||
| 239 | + if dest.is_file(): | ||
| 240 | + return dest | ||
| 241 | + dest.parent.mkdir(parents=True, exist_ok=True) | ||
| 242 | + at = min(1.0, (rush.get("duration") or 0) / 3) | ||
| 243 | + proc = subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-ss", f"{at:.2f}", "-i", str(path), "-frames:v", "1", | ||
| 244 | + "-vf", "scale=320:-2", "-q:v", "5", str(dest)], capture_output=True) | ||
| 245 | + if proc.returncode != 0 or not dest.is_file(): | ||
| 246 | + raise RuntimeError(f"ffmpeg : {proc.stderr.decode(errors='replace')[-300:]}") | ||
| 247 | + for old in dest.parent.glob(f"thumb-{rush['id']}-*.jpg"): | ||
| 248 | + if old != dest: | ||
| 249 | + old.unlink(missing_ok=True) | ||
| 250 | + return dest | ||
| 251 | + | ||
| 252 | + | ||
| 234 | def rush_peaks(pid: str, rush: dict) -> list[int]: | 253 | def rush_peaks(pid: str, rush: dict) -> list[int]: |
| 235 | """La forme d'onde à afficher (0-100) : rapportée au haut de la voix, pas au pic le plus fort, | 254 | """La forme d'onde à afficher (0-100) : rapportée au haut de la voix, pas au pic le plus fort, |
| 236 | pour qu'un claquement isolé n'écrase pas tout le reste.""" | 255 | pour qu'un claquement isolé n'écrase pas tout le reste.""" |