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:
2026-04-30 12:25:08 +00:00
parent c94e6214ca
commit facfd6bd39
2 changed files with 169 additions and 52 deletions
+93 -38
View File
@@ -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();