Edit: instant client-side preview with low-q source
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
This commit is contained in:
parent
63da3ad2e2
commit
c94e6214ca
48
app/main.py
48
app/main.py
@ -1233,14 +1233,58 @@ async def delete_job(job_id: str, user: str = Depends(check_auth)):
|
|||||||
|
|
||||||
# ─── EDIT FEATURE ────────────────────────────────────────────────
|
# ─── EDIT FEATURE ────────────────────────────────────────────────
|
||||||
@app.get("/api/source-video/{job_id}")
|
@app.get("/api/source-video/{job_id}")
|
||||||
async def source_video(job_id: str, user: str = Depends(check_auth)):
|
async def source_video(job_id: str, quality: str = "high", user: str = Depends(check_auth)):
|
||||||
"""Vrne 16:9 original video za predogled v editorju."""
|
"""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)
|
job = load_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(404, "Ne obstaja")
|
raise HTTPException(404, "Ne obstaja")
|
||||||
src = job.get("input_path")
|
src = job.get("input_path")
|
||||||
if not src or not Path(src).exists():
|
if not src or not Path(src).exists():
|
||||||
raise HTTPException(404, "Original video ne obstaja")
|
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(
|
return FileResponse(
|
||||||
path=src,
|
path=src,
|
||||||
media_type="video/mp4",
|
media_type="video/mp4",
|
||||||
|
|||||||
@ -1027,7 +1027,8 @@
|
|||||||
<button class="modal-close" title="Zapri (ESC)">×</button>
|
<button class="modal-close" title="Zapri (ESC)">×</button>
|
||||||
<div class="modal-title" style="margin-bottom:12px;">✏️ Edit: ${escapeHtml(title)}</div>
|
<div class="modal-title" style="margin-bottom:12px;">✏️ Edit: ${escapeHtml(title)}</div>
|
||||||
|
|
||||||
<video id="edit-video" src="/api/source-video/${jobId}" controls preload="metadata" style="width:100%; max-height:50vh; background:#000; border-radius:6px;"></video>
|
<video id="edit-video" src="/api/source-video/${jobId}?quality=low" controls preload="auto" style="width:100%; max-height:50vh; background:#000; border-radius:6px;"></video>
|
||||||
|
<div id="source-status" style="font-size:11px; color:var(--muted); margin-top:4px; text-align:center;">⏳ Pripravljam predogled (~5s prvič, potem instant)…</div>
|
||||||
|
|
||||||
<!-- iPhone-style trim bar -->
|
<!-- iPhone-style trim bar -->
|
||||||
<div id="trim-bar" style="position:relative; height:72px; width:100%; flex-shrink:0; box-sizing:border-box; margin-top:18px; background:#1a1a1a; border:2px solid #444; border-radius:8px; overflow:hidden; user-select:none; touch-action:none;">
|
<div id="trim-bar" style="position:relative; height:72px; width:100%; flex-shrink:0; box-sizing:border-box; margin-top:18px; background:#1a1a1a; border:2px solid #444; border-radius:8px; overflow:hidden; user-select:none; touch-action:none;">
|
||||||
@ -1222,72 +1223,36 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── LIVE PREVIEW: naloži low-q clip označenega dela ───
|
// ─── INSTANT PREVIEW: predvajaj označen del (od trimStart, auto-stop pri trimEnd) ───
|
||||||
window.previewSelection = async function() {
|
window.previewSelection = function() {
|
||||||
const status = document.getElementById("preview-status");
|
if (!video) return;
|
||||||
const playBtn = document.getElementById("preview-btn");
|
// Skoči na trim start in predvajaj
|
||||||
const start = trimStart;
|
video.currentTime = trimStart;
|
||||||
const end = trimEnd;
|
video.play().catch(err => {
|
||||||
|
console.warn("Play failed:", err);
|
||||||
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";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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) {
|
if (video) {
|
||||||
video.addEventListener("timeupdate", () => {
|
video.addEventListener("timeupdate", () => {
|
||||||
// Če gledamo preview clip (src vsebuje '/api/preview-clip/'), ne ustavi
|
if (dragging) return; // Ne ustavi med dragom
|
||||||
if (video.src && video.src.indexOf("/api/preview-clip/") !== -1) return;
|
|
||||||
if (!video.paused && video.currentTime >= trimEnd) {
|
if (!video.paused && video.currentTime >= trimEnd) {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = trimStart;
|
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)
|
// Initial render — počakaj da DOM ima dimenzije (modal je bil pravkar dodan)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user