Live preview in Edit modal — fast low-q clip render
User feedback: 'dejstvo je da trajna ker more najprej zrenderirat? to traja?
za to bi rabili hudo mašino al?'
Solution before GPU upgrade: live preview that renders just the selected
range as low-quality 480p clip. ~2-3s instead of ~70s full reel render.
NEW endpoint: GET /api/preview-clip/{job_id}?start=X&end=Y
- ffmpeg fast extract (no reframe, no subtitles, no face tracking)
- 480p ultrafast x264 preset, CRF 30
- Cached per job+range (re-clicks are instant)
- ~2-3s on CPU
Frontend:
- '▶ Predvajaj odsek' button now triggers preview-clip render
- Shows status: '🎬 Renderiram odsek... (~3s)'
- After render: video element switches to preview src
- User sees EXACTLY what reel will contain (just without face track)
- Subsequent clicks on same range are instant (cached)
Workflow:
- Drag handles → click '▶ Predvajaj odsek' → 3s wait → see + hear it
- Iterate fast: drag → preview → drag → preview
- Final '✅ Shrani in re-render' only when satisfied (~70s full render)
This commit is contained in:
parent
274ff80b34
commit
0513768466
66
app/main.py
66
app/main.py
@ -1249,6 +1249,72 @@ async def source_video(job_id: str, user: str = Depends(check_auth)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/preview-clip/{job_id}")
|
||||||
|
async def preview_clip(
|
||||||
|
job_id: str,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
user: str = Depends(check_auth),
|
||||||
|
):
|
||||||
|
"""Live preview odseka — low-quality 480p clip za hiter predogled.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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 end <= start:
|
||||||
|
raise HTTPException(400, "end mora biti večji od start")
|
||||||
|
duration = end - start
|
||||||
|
if duration < 1:
|
||||||
|
raise HTTPException(400, "Trajanje vsaj 1s")
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
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", # 480p
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "ultrafast", # NAJHITREJŠI preset
|
||||||
|
"-crf", "30", # nižja kvaliteta = hitrejše
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "96k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-loglevel", "error",
|
||||||
|
str(cache_path),
|
||||||
|
]
|
||||||
|
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:]}")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(500, "Preview render timeout (>20s)")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=cache_path,
|
||||||
|
media_type="video/mp4",
|
||||||
|
headers={"Accept-Ranges": "bytes", "Cache-Control": "max-age=300"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/transcript/{job_id}")
|
@app.get("/api/transcript/{job_id}")
|
||||||
async def get_transcript(job_id: str, user: str = Depends(check_auth)):
|
async def get_transcript(job_id: str, user: str = Depends(check_auth)):
|
||||||
"""Vrne transkript (segmente + words) za editor."""
|
"""Vrne transkript (segmente + words) za editor."""
|
||||||
|
|||||||
@ -1065,10 +1065,12 @@
|
|||||||
<span style="color:var(--muted); margin-left:12px;">Trajanje:</span> <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b>
|
<span style="color:var(--muted); margin-left:12px;">Trajanje:</span> <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button class="primary" onclick="seekEditVideo('start')" title="Predvajaj označen del" style="background:var(--accent); padding:8px 16px;">▶ Predvajaj odsek</button>
|
<button class="primary" id="preview-btn" onclick="previewSelection()" title="Renderiraj in predvajaj točno označen del" style="background:var(--accent); padding:8px 16px;">▶ Predvajaj odsek</button>
|
||||||
|
<button class="small ghost" onclick="seekEditVideo('start')" title="Skoči na začetek">⤴ Začetek</button>
|
||||||
<button class="small ghost" onclick="seekEditVideo('end')" title="Skoči na konec">↪ Konec</button>
|
<button class="small ghost" onclick="seekEditVideo('end')" title="Skoči na konec">↪ Konec</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="preview-status" style="margin-top:6px; font-size:12px; color:var(--muted); text-align:right;"></div>
|
||||||
|
|
||||||
<details style="margin-top:14px;">
|
<details style="margin-top:14px;">
|
||||||
<summary style="cursor:pointer; font-size:13px; color:var(--muted);">📝 Edit napise (kliknite vrstico za popravek)</summary>
|
<summary style="cursor:pointer; font-size:13px; color:var(--muted);">📝 Edit napise (kliknite vrstico za popravek)</summary>
|
||||||
@ -1220,12 +1222,70 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-stop predvajanje ko doseže trimEnd (kot iPhone trim preview)
|
// ─── 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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-stop predvajanje ko doseže trimEnd (samo če gledamo full video)
|
||||||
if (video) {
|
if (video) {
|
||||||
video.addEventListener("timeupdate", () => {
|
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 (!video.paused && video.currentTime >= trimEnd) {
|
if (!video.paused && video.currentTime >= trimEnd) {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = trimStart; // reset na začetek označenega dela
|
video.currentTime = trimStart;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user