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:
+76
-14
@@ -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}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user