diff --git a/app/main.py b/app/main.py index 83fa29b..bfd6cae 100644 --- a/app/main.py +++ b/app/main.py @@ -1293,6 +1293,58 @@ async def source_video(job_id: str, quality: str = "high", user: str = Depends(c ) +@app.get("/api/waveform/{job_id}") +async def waveform(job_id: str, width: int = 1200, height: int = 80, user: str = Depends(check_auth)): + """Vrne PNG waveform celotne pesmi za visualizacijo v Edit modalu. + + Cache enkrat per pesem, file size ~10-50 KB. + """ + job = load_job(job_id) + if not job: + raise HTTPException(404, "Ne obstaja") + src = job.get("input_path") + if not src or not Path(src).exists(): + raise HTTPException(404, "Original video ne obstaja") + + width = max(400, min(width, 3000)) + height = max(40, min(height, 200)) + + cache_path = OUTPUT_DIR / f"{job_id}_waveform_{width}x{height}.png" + cache_valid = cache_path.exists() and cache_path.stat().st_size > 100 + + if not cache_valid: + if cache_path.exists(): + cache_path.unlink() + # ffmpeg showwavespic filter — generira en sam PNG s celotnim waveformom + # colors: rdeč #ff6b6b kot accent + cmd = [ + "ffmpeg", "-y", + "-i", str(src), + "-filter_complex", + f"[0:a]aformat=channel_layouts=mono,showwavespic=s={width}x{height}:colors=#ff6b6b:scale=lin:draw=full[wave]", + "-map", "[wave]", + "-frames:v", "1", + "-loglevel", "error", + str(cache_path), + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if proc.returncode != 0 or not cache_path.exists() or cache_path.stat().st_size < 100: + if cache_path.exists(): + cache_path.unlink() + raise HTTPException(500, f"Waveform render failed: {(proc.stderr or 'unknown')[-300:]}") + except subprocess.TimeoutExpired: + if cache_path.exists(): + cache_path.unlink() + raise HTTPException(500, "Waveform render timeout (>30s)") + + return FileResponse( + path=cache_path, + media_type="image/png", + headers={"Cache-Control": "max-age=3600"}, + ) + + @app.api_route("/api/preview-clip/{job_id}", methods=["GET", "HEAD"]) async def preview_clip( job_id: str, @@ -1300,13 +1352,13 @@ async def preview_clip( end: float, user: str = Depends(check_auth), ): - """Live preview odseka — low-quality 480p clip za hiter predogled. + """Live preview odseka + 10s konteksta pred/po — low-quality 480p clip. + Vrne odsek od (start-10s) do (end+10s), 480p quality. + Frontend lahko free-scruba v tem range-u + drag-a handle. BREZ reframe (16:9 ostane), BREZ napisov, BREZ face tracking. - 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). + Cache po start+end timestampih. """ job = load_job(job_id) if not job: @@ -1323,25 +1375,30 @@ async def preview_clip( if duration > 90: raise HTTPException(400, "Trajanje največ 90s") - # Cache key — preview se shrani in ne re-renderira če isti range - cache_key = f"{job_id}_preview_{start:.1f}_{end:.1f}.mp4" + # Razširi z 10s pred in po (kontekst za fine-tune) + CONTEXT_BEFORE = 10.0 + CONTEXT_AFTER = 10.0 + extract_start = max(0, start - CONTEXT_BEFORE) + extract_end = end + CONTEXT_AFTER + extract_duration = extract_end - extract_start + + # Cache key — razširjen preview se shrani po orig start+end + cache_key = f"{job_id}_preview_ctx_{start:.1f}_{end:.1f}.mp4" cache_path = OUTPUT_DIR / cache_key # 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 + "-ss", f"{max(0, extract_start - 0.5):.2f}", "-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 + "-ss", f"{min(0.5, extract_start):.2f}", + "-t", f"{extract_duration:.2f}", + "-vf", "scale=854:480:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", @@ -1354,7 +1411,6 @@ 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() 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:]}") @@ -1366,7 +1422,13 @@ async def preview_clip( return FileResponse( path=cache_path, media_type="video/mp4", - headers={"Accept-Ranges": "bytes", "Cache-Control": "max-age=300"}, + headers={ + "Accept-Ranges": "bytes", + "Cache-Control": "max-age=300", + # Vrnem original start kot custom header da frontend ve mapiranje + "X-Preview-Original-Start": f"{extract_start:.2f}", + "X-Preview-Original-End": f"{extract_end:.2f}", + }, ) diff --git a/templates/index.html b/templates/index.html index c9c6458..0c7344f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1023,39 +1023,67 @@ const overlay = document.createElement("div"); overlay.className = "modal-overlay"; overlay.innerHTML = ` -