Preview-clip: validate cache, support HEAD, cleanup on fail

Bugs from puppeteer inspection:
1. Old buggy renders left 0-byte cache files behind. New code never
   re-rendered because cache_path.exists() was True.
   Fix: validate cache file is >1KB, otherwise re-render.

2. FastAPI @app.get only handles GET, not HEAD. Frontend's HEAD check
   returned 405, then GET re-rendered (correct), but second click also
   returned 405 then 200 again — confusing.
   Fix: use @app.api_route with methods=['GET', 'HEAD']

3. If ffmpeg fails partway, broken file remains in cache.
   Fix: unlink on any failure path.

Also deleted existing empty cache files in container.
This commit is contained in:
Sebastjan Artič 2026-04-30 12:05:11 +00:00
parent 69062205fd
commit 63da3ad2e2

View File

@ -1249,7 +1249,7 @@ async def source_video(job_id: str, user: str = Depends(check_auth)):
)
@app.get("/api/preview-clip/{job_id}")
@app.api_route("/api/preview-clip/{job_id}", methods=["GET", "HEAD"])
async def preview_clip(
job_id: str,
start: float,
@ -1262,6 +1262,7 @@ async def preview_clip(
Cilj: ~2-3s render za takojšen feedback med Edit dragom.
Cache po start+end timestampih da ponovljeni request ne renderira ponovno.
Podpira HEAD (frontend cache check).
"""
job = load_job(job_id)
if not job:
@ -1282,19 +1283,24 @@ async def preview_clip(
cache_key = f"{job_id}_preview_{start:.1f}_{end:.1f}.mp4"
cache_path = OUTPUT_DIR / cache_key
if not cache_path.exists():
# ffmpeg low-q clip — copy stream če gre, sicer hitro re-encode
# -ss pred -i: hiter seek (keyframe), idealno za preview
# Validate cache: datoteka mora obstajati IN biti vsaj 1KB
cache_valid = cache_path.exists() and cache_path.stat().st_size > 1024
if not cache_valid:
# Briši staro prazno če obstaja
if cache_path.exists():
cache_path.unlink()
# ffmpeg low-q clip — fast seek + force even dimensions for libx264
cmd = [
"ffmpeg", "-y",
"-ss", f"{max(0, start - 0.5):.2f}", # 0.5s buffer za keyframe
"-i", str(src),
"-ss", f"{min(0.5, start):.2f}", # fine seek
"-t", f"{duration:.2f}",
"-vf", "scale=854:480:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2", # 480p, even dimensions for libx264
"-vf", "scale=854:480:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2", # 480p, even dimensions
"-c:v", "libx264",
"-preset", "ultrafast", # NAJHITREJŠI preset
"-crf", "30", # nižja kvaliteta = hitrejše
"-preset", "ultrafast",
"-crf", "30",
"-c:a", "aac",
"-b:a", "96k",
"-movflags", "+faststart",
@ -1303,9 +1309,14 @@ async def preview_clip(
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
if proc.returncode != 0 or not cache_path.exists():
raise HTTPException(500, f"FFmpeg failed: {proc.stderr[-300:]}")
if proc.returncode != 0 or not cache_path.exists() or cache_path.stat().st_size < 1024:
# Briši nepopolno datoteko
if cache_path.exists():
cache_path.unlink()
raise HTTPException(500, f"FFmpeg failed: {(proc.stderr or 'unknown')[-300:]}")
except subprocess.TimeoutExpired:
if cache_path.exists():
cache_path.unlink()
raise HTTPException(500, "Preview render timeout (>20s)")
return FileResponse(