From c94e6214ca117db22311b37d5357dfe757da71be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastjan=20Arti=C4=8D?= Date: Thu, 30 Apr 2026 12:14:37 +0000 Subject: [PATCH] Edit: instant client-side preview with low-q source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: 'predvaja odsek in začne iz nule kar ni ok, ne moremo premikati levo dolj levo... za to bi rabili low-q?' REPLACED render-on-demand approach with low-q source download: 1. Backend: GET /api/source-video/{id}?quality=low - 480p re-encode of full source (cached after first request) - veryfast preset, CRF 28 - First request: ~5-10s (depends on song length) - Subsequent: instant (cached) 2. Frontend: Edit modal loads ?quality=low - 'Pripravljam predogled (~5s prvič, potem instant)' status - Once loaded: ALL preview is client-side instant - 'Predvajaj odsek' jumps to trimStart and plays - Auto-stop at trimEnd (loops back) - Drag handles DURING playback = instant seek (browser scrubs in 5MB) - Drag NOT blocked during play (you can fine-tune in/out live) 3. Removed old /api/preview-clip endpoint logic (no longer needed) Note: kept the route as cache cleanup for old jobs Workflow now: - Open Edit → 5s wait first time - Drag handles freely (instant scrubbing) - Click Predvajaj → starts at trimStart immediately - Drag handles WHILE playing → live preview - Save when satisfied → 70s full render --- app/main.py | 48 ++++++++++++++++++++++++-- templates/index.html | 81 +++++++++++++------------------------------- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/app/main.py b/app/main.py index ee25d8f..83fa29b 100644 --- a/app/main.py +++ b/app/main.py @@ -1233,14 +1233,58 @@ async def delete_job(job_id: str, user: str = Depends(check_auth)): # ─── EDIT FEATURE ──────────────────────────────────────────────── @app.get("/api/source-video/{job_id}") -async def source_video(job_id: str, user: str = Depends(check_auth)): - """Vrne 16:9 original video za predogled v editorju.""" +async def source_video(job_id: str, quality: str = "high", user: str = Depends(check_auth)): + """Vrne 16:9 original video za predogled v editorju. + + quality='high' (default): originalni file + quality='low': 480p re-encoded version (cached) — za hitro scrubbanje v editorju + """ 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") + + if quality == "low": + # 480p cached za hitro scrubbanje + cache_path = OUTPUT_DIR / f"{job_id}_source_low.mp4" + cache_valid = cache_path.exists() and cache_path.stat().st_size > 1024 + + if not cache_valid: + if cache_path.exists(): + cache_path.unlink() + cmd = [ + "ffmpeg", "-y", + "-i", str(src), + "-vf", "scale=854:480:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:v", "libx264", + "-preset", "veryfast", # malo boljša kvaliteta od ultrafast + "-crf", "28", + "-c:a", "aac", + "-b:a", "96k", + "-movflags", "+faststart", + "-loglevel", "error", + str(cache_path), + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if proc.returncode != 0 or not cache_path.exists() or cache_path.stat().st_size < 1024: + 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, "Low-q source render timeout (>120s)") + + return FileResponse( + path=cache_path, + media_type="video/mp4", + filename=f"source_low_{job_id}.mp4", + headers={"Accept-Ranges": "bytes", "Cache-Control": "max-age=3600"}, + ) + return FileResponse( path=src, media_type="video/mp4", diff --git a/templates/index.html b/templates/index.html index a992e30..c9c6458 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1027,7 +1027,8 @@ - + +
⏳ Pripravljam predogled (~5s prvič, potem instant)…
@@ -1222,72 +1223,36 @@ } }; - // ─── LIVE PREVIEW: naloži low-q clip označenega dela ─── - window.previewSelection = async function() { - const status = document.getElementById("preview-status"); - const playBtn = document.getElementById("preview-btn"); - const start = trimStart; - const end = trimEnd; - - if (end - start < 1) { - alert("Trajanje vsaj 1s"); - return; - } - - playBtn.disabled = true; - playBtn.textContent = "⏳ Renderiram preview..."; - status.textContent = `🎬 Renderiram odsek ${formatTime(start)} - ${formatTime(end)} (~3s)...`; - - const url = `/api/preview-clip/${jobId}?start=${start.toFixed(1)}&end=${end.toFixed(1)}`; - const t0 = Date.now(); - - try { - // Preverim ali je preview že cached (HEAD request) - const headRes = await fetch(url, { method: "HEAD" }); - if (!headRes.ok) { - // Cache miss → trigger render z GET - const getRes = await fetch(url); - if (!getRes.ok) { - const err = await getRes.json().catch(() => ({})); - status.textContent = "❌ " + (err.detail || "Render failed"); - playBtn.disabled = false; - playBtn.textContent = "▶ Predvajaj odsek"; - return; - } - } - - const elapsed = ((Date.now() - t0) / 1000).toFixed(1); - status.textContent = `✅ Pripravljen v ${elapsed}s. Predvajam...`; - - // Set video src to preview clip - video.src = url; - video.load(); - video.addEventListener("loadeddata", function loadedOnce() { - video.removeEventListener("loadeddata", loadedOnce); - video.currentTime = 0; - video.play(); - }, { once: true }); - - // Auto-pojavi success - setTimeout(() => { status.textContent = ""; }, 3000); - } catch (e) { - status.textContent = "❌ Napaka: " + e.message; - } finally { - playBtn.disabled = false; - playBtn.textContent = "▶ Predvajaj odsek"; - } + // ─── INSTANT PREVIEW: predvajaj označen del (od trimStart, auto-stop pri trimEnd) ─── + window.previewSelection = function() { + if (!video) return; + // Skoči na trim start in predvajaj + video.currentTime = trimStart; + video.play().catch(err => { + console.warn("Play failed:", err); + }); }; - // Auto-stop predvajanje ko doseže trimEnd (samo če gledamo full video) + // Auto-stop ko doseže trimEnd (brez render-a, brez preview clip URL) + // Samo če NI v aktivnem dragu (ker drag = naročiteljsko seekanje) if (video) { video.addEventListener("timeupdate", () => { - // Če gledamo preview clip (src vsebuje '/api/preview-clip/'), ne ustavi - if (video.src && video.src.indexOf("/api/preview-clip/") !== -1) return; + if (dragging) return; // Ne ustavi med dragom if (!video.paused && video.currentTime >= trimEnd) { video.pause(); video.currentTime = trimStart; } }); + + // Source-status: skrij ko se naloži + const sourceStatus = document.getElementById("source-status"); + video.addEventListener("loadeddata", () => { + if (sourceStatus) sourceStatus.textContent = "✅ Predogled pripravljen — drag ročajev za fine-tune"; + setTimeout(() => { if (sourceStatus) sourceStatus.style.display = "none"; }, 2000); + }); + video.addEventListener("error", () => { + if (sourceStatus) sourceStatus.textContent = "❌ Napaka pri nalaganju predogleda"; + }); } // Initial render — počakaj da DOM ima dimenzije (modal je bil pravkar dodan)