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
+76 -14
View File
@@ -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}",
},
)