Edit modal: waveform + napisi desno + live highlight
User feedback: 'če bi imeli spodaj wave strukturo bi se po tem prmikali,
in narediti da teksti laufajo na desni strani ob robu videja'
NEW: backend /api/waveform/{id}?width&height
- ffmpeg showwavespic generates PNG (~10-50KB)
- Cached forever per song
- Red color (#ff6b6b) matching accent
Frontend layout RESTRUCTURED:
- 1200px max-width (was 900px)
- Top section: GRID 1fr / 320px
- LEFT: video (16:9)
- RIGHT: napisi panel (sticky header, scrollable, 55vh max)
- Bottom: trim bar full-width with WAVEFORM as background image
- Hint text updated: 'Klik na valove ali napise = skoči video'
INTERACTIONS:
- Click segment row → seekToSegment() jumps video to that timestamp
- Live highlight: gold (#ffd700) on currently playing segment
- Auto-scroll panel to keep active segment in view
- Drag handles updates segment row colors (in-clip = red bg, outside = gray)
- Click on trim bar (waveform) still works as seek
User can now:
- See visual audio shape (loud parts = vocals, quiet = instrumental)
- See ALL napisi at once on the right
- Click any napis to jump to it
- Watch live highlight follow the song
- Edit any napis text inline
This commit is contained in:
parent
c94e6214ca
commit
facfd6bd39
90
app/main.py
90
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}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1023,39 +1023,67 @@
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content edit-modal" onclick="event.stopPropagation()" style="max-width:900px;">
|
||||
<div class="modal-content edit-modal" onclick="event.stopPropagation()" style="max-width:1200px; width:95vw;">
|
||||
<button class="modal-close" title="Zapri (ESC)">×</button>
|
||||
<div class="modal-title" style="margin-bottom:12px;">✏️ Edit: ${escapeHtml(title)}</div>
|
||||
|
||||
<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 -->
|
||||
<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;">
|
||||
<!-- Selected region (highlighted) -->
|
||||
<div id="trim-region" style="position:absolute; top:0; bottom:0; left:${pctOfStr(startInit, videoDuration)}%; right:${(100 - parseFloat(pctOfStr(endInit, videoDuration))).toFixed(2)}%; background:linear-gradient(180deg, rgba(255,107,107,0.35), rgba(255,107,107,0.2)); border-top:4px solid #ff6b6b; border-bottom:4px solid #ff6b6b; z-index:1;"></div>
|
||||
|
||||
<!-- Left handle -->
|
||||
<div id="trim-handle-left" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(startInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
|
||||
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
|
||||
<!-- Top section: video LEVO + napisi DESNO -->
|
||||
<div style="display:grid; grid-template-columns: 1fr 320px; gap:14px; align-items:start;">
|
||||
<!-- LEFT: video -->
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Right handle -->
|
||||
<div id="trim-handle-right" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(endInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
|
||||
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
|
||||
<!-- RIGHT: napisi -->
|
||||
<div style="background:rgba(255,255,255,0.03); border-radius:6px; padding:10px; max-height:55vh; overflow-y:auto;">
|
||||
<div style="font-size:13px; font-weight:bold; margin-bottom:8px; color:var(--muted); position:sticky; top:0; background:#1e1e1e; padding:6px 0;">📝 Napisi (klikni vrstico = skoči, edit besedilo)</div>
|
||||
<div id="edit-segments">
|
||||
${segments.map((s, i) => {
|
||||
const inClip = s.start < endInit && s.end > startInit;
|
||||
return `
|
||||
<div class="seg-row" data-idx="${i}" data-start="${s.start}" data-end="${s.end}" style="margin-bottom:4px; padding:5px 6px; background:${inClip ? 'rgba(255,107,107,0.12)' : 'rgba(255,255,255,0.03)'}; border-left:2px solid ${inClip ? '#ff6b6b' : 'transparent'}; border-radius:3px; cursor:pointer;" onclick="seekToSegment(${s.start})">
|
||||
<div style="font-size:10px; color:var(--muted);">[${formatTime(s.start)} → ${formatTime(s.end)}]</div>
|
||||
<input type="text" data-orig="${escapeHtml(s.text || '')}" data-start="${s.start}" data-end="${s.end}" value="${escapeHtml(s.text || '').replace(/\\n/g, ' ').trim()}" onclick="event.stopPropagation()" style="width:100%; padding:3px 6px; margin-top:3px; font-size:12px; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.1); border-radius:3px; color:#fff; box-sizing:border-box;">
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playhead (current video position) -->
|
||||
<div id="trim-playhead" style="position:absolute; top:-4px; bottom:-4px; left:0%; width:3px; background:#fff; z-index:2; pointer-events:none; opacity:0.8; box-shadow:0 0 4px #fff;"></div>
|
||||
|
||||
<!-- Time labels -->
|
||||
<div style="position:absolute; bottom:4px; left:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;">0:00</div>
|
||||
<div style="position:absolute; bottom:4px; right:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;" id="trim-end-label">${formatTime(videoDuration)}</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint -->
|
||||
<div style="font-size:11px; color:var(--muted); margin-top:6px; text-align:center;">
|
||||
← Povleci levi rdeč ročaj za začetek · Povleci desni rdeč ročaj za konec →
|
||||
<!-- iPhone-style trim bar + WAVEFORM spodaj (full width) -->
|
||||
<div style="margin-top:18px;">
|
||||
<!-- Trim bar -->
|
||||
<div id="trim-bar" style="position:relative; height:72px; width:100%; flex-shrink:0; box-sizing:border-box; background:#1a1a1a; border:2px solid #444; border-radius:8px 8px 0 0; overflow:hidden; user-select:none; touch-action:none;">
|
||||
<!-- Waveform image (background) -->
|
||||
<img id="trim-waveform" src="/api/waveform/${jobId}?width=1200&height=72" style="position:absolute; top:0; left:0; width:100%; height:100%; opacity:0.6; pointer-events:none; z-index:0;" onerror="this.style.display='none'">
|
||||
|
||||
<!-- Selected region -->
|
||||
<div id="trim-region" style="position:absolute; top:0; bottom:0; left:${pctOfStr(startInit, videoDuration)}%; right:${(100 - parseFloat(pctOfStr(endInit, videoDuration))).toFixed(2)}%; background:linear-gradient(180deg, rgba(255,107,107,0.35), rgba(255,107,107,0.2)); border-top:4px solid #ff6b6b; border-bottom:4px solid #ff6b6b; z-index:1;"></div>
|
||||
|
||||
<!-- Left handle -->
|
||||
<div id="trim-handle-left" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(startInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
|
||||
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right handle -->
|
||||
<div id="trim-handle-right" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(endInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
|
||||
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Playhead -->
|
||||
<div id="trim-playhead" style="position:absolute; top:-4px; bottom:-4px; left:0%; width:3px; background:#fff; z-index:2; pointer-events:none; opacity:0.8; box-shadow:0 0 4px #fff;"></div>
|
||||
|
||||
<!-- Time labels -->
|
||||
<div style="position:absolute; bottom:4px; left:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;">0:00</div>
|
||||
<div style="position:absolute; bottom:4px; right:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;" id="trim-end-label">${formatTime(videoDuration)}</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint -->
|
||||
<div style="font-size:11px; color:var(--muted); margin-top:6px; text-align:center;">
|
||||
← Povleci levi/desni rdeč ročaj · Klik na valove ali napise = skoči video
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time display + controls -->
|
||||
@ -1066,25 +1094,13 @@
|
||||
<span style="color:var(--muted); margin-left:12px;">Trajanje:</span> <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<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="primary" id="preview-btn" onclick="previewSelection()" title="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>
|
||||
</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;">
|
||||
<summary style="cursor:pointer; font-size:13px; color:var(--muted);">📝 Edit napise (kliknite vrstico za popravek)</summary>
|
||||
<div id="edit-segments" style="max-height:30vh; overflow:auto; margin-top:10px; padding:10px; background:rgba(255,255,255,0.03); border-radius:6px;">
|
||||
${segments.filter(s => s.start < endInit && s.end > startInit).map((s, i) => `
|
||||
<div class="seg-row" data-idx="${i}" style="margin-bottom:6px; padding:6px; background:rgba(255,255,255,0.04); border-radius:4px;">
|
||||
<span style="font-size:11px; color:var(--muted); margin-right:8px;">[${s.start.toFixed(1)}s]</span>
|
||||
<input type="text" data-orig="${escapeHtml(s.text || '')}" data-start="${s.start}" data-end="${s.end}" value="${escapeHtml(s.text || '').replace(/\\n/g, ' ').trim()}" style="width:calc(100% - 80px); padding:4px 8px; font-size:13px; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.1); border-radius:3px; color:#fff;">
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="modal-actions" style="margin-top:18px;">
|
||||
<button class="primary" id="edit-save-btn">✅ Shrani in re-render</button>
|
||||
<button onclick="closeModal()">Prekliči</button>
|
||||
@ -1176,6 +1192,12 @@
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (dragging) {
|
||||
// Po dragu posodobi napis highlights (kateri so zdaj v clipu)
|
||||
if (typeof highlightActiveSegment === 'function') {
|
||||
highlightActiveSegment();
|
||||
}
|
||||
}
|
||||
dragging = null;
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
@ -1223,6 +1245,38 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Klik na napis → skoči video na tisti timestamp
|
||||
window.seekToSegment = function(t) {
|
||||
if (!video) return;
|
||||
video.currentTime = t;
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
|
||||
// Live highlight aktivnega segmenta med predvajanjem
|
||||
function highlightActiveSegment() {
|
||||
if (!video) return;
|
||||
const t = video.currentTime;
|
||||
const rows = document.querySelectorAll(".seg-row");
|
||||
rows.forEach(row => {
|
||||
const sStart = parseFloat(row.dataset.start);
|
||||
const sEnd = parseFloat(row.dataset.end);
|
||||
const isActive = t >= sStart && t < sEnd;
|
||||
if (isActive) {
|
||||
row.style.background = "rgba(255, 215, 0, 0.25)";
|
||||
row.style.borderLeft = "2px solid #ffd700";
|
||||
if (!row._scrolledTo) {
|
||||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
row._scrolledTo = true;
|
||||
setTimeout(() => { row._scrolledTo = false; }, 500);
|
||||
}
|
||||
} else {
|
||||
const inClip = (sStart < trimEnd && sEnd > trimStart);
|
||||
row.style.background = inClip ? "rgba(255,107,107,0.12)" : "rgba(255,255,255,0.03)";
|
||||
row.style.borderLeft = "2px solid " + (inClip ? "#ff6b6b" : "transparent");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── INSTANT PREVIEW: predvajaj označen del (od trimStart, auto-stop pri trimEnd) ───
|
||||
window.previewSelection = function() {
|
||||
if (!video) return;
|
||||
@ -1237,6 +1291,7 @@
|
||||
// Samo če NI v aktivnem dragu (ker drag = naročiteljsko seekanje)
|
||||
if (video) {
|
||||
video.addEventListener("timeupdate", () => {
|
||||
highlightActiveSegment(); // Live highlight aktivnega napisa
|
||||
if (dragging) return; // Ne ustavi med dragom
|
||||
if (!video.paused && video.currentTime >= trimEnd) {
|
||||
video.pause();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user