From 7cb4302dcd200fd0c2ea60cee08c506eef3ef91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastjan=20Arti=C4=8D?= Date: Thu, 30 Apr 2026 10:26:25 +0000 Subject: [PATCH] Edit feature: slider + napis edit + recut endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User insight: 'treba je narediti da ko se reels naredijo da jih lahko popravljamo... delamo na avtomatiko ampak lahk pa tudi popravljam' Avto pipeline ostane (Soniox + Claude + render). Po render-u uporabnik lahko klikne ✏️ Edit gumb in: 1. **Slider za clip start/end**: - Vidi 16:9 original video - Drag start/end slider z živim preview-om - Dolžina prikazana real-time - Min 5s, max 60s 2. **Edit napisov** (collapsed, opcijsko): - Klik na vrstico → input za popravek besedila - Original timestamp ostane, samo besedilo se posodobi - Uporabno za 'doline IZBOR' → 'doline IZPOD' tip popravkov 3. **Re-render**: - Backend POST /api/jobs/{id}/recut z {start, end, custom_segments} - Worker preskoči Soniox + Claude (custom_clip flag) - Re-uporabi cached transcript + analysis - Re-render samo: clip → reframe → subtitle → output - ~30s namesto 3-5 min New endpoints: - GET /api/source-video/{id} — 16:9 original za editor preview - GET /api/transcript/{id} — segmenti + clip range za editor - POST /api/jobs/{id}/recut — re-render z user timestampi Worker change: če job ima custom_clip=True, preskoči auto_chorus analizo in samo re-uporabi obstoječi clip_range iz analysis.json (updated by recut endpoint). --- app/main.py | 200 ++++++++++++++++++++++++++++++++++++++++++- scripts/analyze.py | 2 +- templates/index.html | 185 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 6acb057..458e5ef 100644 --- a/app/main.py +++ b/app/main.py @@ -349,18 +349,40 @@ def generate_srt_from_segments(segments, clip_start, clip_end, output_path): if words_in_clip and len(words_in_clip) >= 2: # Group besede v chunke z max trajanjem MAX_CHUNK_DURATION + # Pravila: + # 1. Vsak chunk traja max MAX_CHUNK_DURATION (2.5s) + # 2. NE začni novega chunka če bi prejšnji ostal kratek (<3 besede ali <12 znakov) + # 3. NE pusti zadnji chunk siroto z 1 kratko besedo (<3 znakov) — združi nazaj + MIN_CHUNK_WORDS = 3 + MIN_CHUNK_CHARS = 12 chunks = [] current_chunk = [words_in_clip[0]] for w in words_in_clip[1:]: chunk_start_time = current_chunk[0]["start"] chunk_dur_so_far = w["end"] - chunk_start_time + + # Bi začeli nov chunk? if chunk_dur_so_far > MAX_CHUNK_DURATION: - chunks.append(current_chunk) - current_chunk = [w] + # Preveri ali bi current_chunk ostal "siroto kratek" + current_text = " ".join(c["text"] for c in current_chunk) + too_short = (len(current_chunk) < MIN_CHUNK_WORDS + or len(current_text) < MIN_CHUNK_CHARS) + if too_short: + # Daj še to besedo zraven (raje malo predolg chunk kot siroto kratek) + current_chunk.append(w) + else: + chunks.append(current_chunk) + current_chunk = [w] else: current_chunk.append(w) if current_chunk: - chunks.append(current_chunk) + # Če zadnji chunk ima samo 1-2 kratki besedi, združi z zadnjim chunkom + last_text = " ".join(c["text"] for c in current_chunk) + if (chunks and + (len(current_chunk) < 2 or len(last_text) < 5)): + chunks[-1].extend(current_chunk) + else: + chunks.append(current_chunk) # Generiraj SRT iz chunks (z dejanskimi word timestampi) for chunk in chunks: @@ -490,7 +512,50 @@ def process_job(job_id): print(f"⚠️ ACR error: {e}", flush=True) # ── 2. Smart analysis (če auto_chorus) ────────────────────────── - if job.get("auto_chorus"): + # ÄŒe je custom_clip=True (user-edit mode), preskoÄi analizo + # in samo posodobi start/duration iz obstojeÄega clip_range v analysis.json + if job.get("custom_clip"): + update_job(job_id, current_step="User-edit recut (preskočim analizo)") + analysis_path = OUTPUT_DIR / f"{job_id}.analysis.json" + srt_from_claude = None + if analysis_path.exists(): + try: + with open(analysis_path, "r", encoding="utf-8") as f: + analysis = json.load(f) + cr = analysis["clip_range"] + fade = analysis.get("fade", {"fade_in": 0.05, "fade_out": 0.5}) + + # Generiraj nov SRT iz obstoječega transkripta na novem range-u + if analysis.get("transcript", {}).get("segments") and not job.get("no_subs"): + srt_path_out = OUTPUT_DIR / f"{job_id}.subtitles.srt" + try: + # Če imamo custom_subtitle_segments (user popravil napise) + segs_to_use = analysis.get("custom_subtitle_segments") or analysis["transcript"]["segments"] + generate_srt_from_segments( + segs_to_use, + cr["start"], cr["end"], + srt_path_out, + ) + srt_from_claude = str(srt_path_out) + except Exception as e: + print(f"⚠️ Recut SRT generation failed: {e}", flush=True) + + update_job( + job_id, + start=cr["start"], + duration=cr["duration"], + fade_in=fade.get("fade_in", 0.05), + fade_out=fade.get("fade_out", 0.5), + claude_srt_path=srt_from_claude, + ) + job = load_job(job_id) + except Exception as e: + update_job(job_id, status="failed", error=f"Recut analysis read: {e}") + return + else: + update_job(job_id, status="failed", error="Analysis manjka za recut") + return + elif job.get("auto_chorus"): update_job(job_id, current_step="Analiza pesmi (transkript + energija)") analysis_path = OUTPUT_DIR / f"{job_id}.analysis.json" cmd = [ @@ -1164,3 +1229,130 @@ async def delete_job(job_id: str, user: str = Depends(check_auth)): Path(p).unlink(missing_ok=True) job_path(job_id).unlink(missing_ok=True) return {"deleted": job_id} + + +# ─── EDIT FEATURE ──────────────────────────────────────────────── +@app.get("/api/source-video/{job_id}") +async def source_video(job_id: str, user: str = Depends(check_auth)): + """Vrne 16:9 original video za predogled v editorju.""" + 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") + return FileResponse( + path=src, + media_type="video/mp4", + filename=f"source_{job_id}.mp4", + headers={"Accept-Ranges": "bytes"}, + ) + + +@app.get("/api/transcript/{job_id}") +async def get_transcript(job_id: str, user: str = Depends(check_auth)): + """Vrne transkript (segmente + words) za editor.""" + job = load_job(job_id) + if not job: + raise HTTPException(404, "Ne obstaja") + analysis_path = OUTPUTS_DIR / f"{job_id}.analysis.json" + if not analysis_path.exists(): + raise HTTPException(404, "Analysis ne obstaja") + try: + analysis = json.loads(analysis_path.read_text()) + transcript = analysis.get("transcript", {}) + clip_range = analysis.get("clip_range", {}) + return { + "segments": transcript.get("segments", []), + "language": transcript.get("language", "?"), + "clip_range": clip_range, + "video_duration": job.get("video_duration", 0), + } + except Exception as e: + raise HTTPException(500, f"Napaka pri branju: {e}") + + +class RecutRequest(BaseModel): + start: float + end: float + custom_segments: Optional[list] = None # [{start, end, text}] za override napisov + no_subs: Optional[bool] = None + + +@app.post("/api/jobs/{job_id}/recut") +async def recut_job(job_id: str, payload: RecutRequest, user: str = Depends(check_auth)): + """Re-rendar reel z user-defined timestampi (in opcijsko popravljenimi napisi). + + Veliko hitrejše od full pipeline-a: + - Brez Soniox transkripta (re-uporabi shranjenega) + - Brez Claude analize (uporabnik je odločil) + - Samo: clip → reframe → subtitle → output + """ + 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(400, "Original video manjka") + + if payload.end <= payload.start: + raise HTTPException(400, "End mora biti večji od start") + if payload.end - payload.start < 5: + raise HTTPException(400, "Trajanje vsaj 5s") + if payload.end - payload.start > 60: + raise HTTPException(400, "Trajanje največ 60s") + + duration = payload.end - payload.start + no_subs = payload.no_subs if payload.no_subs is not None else job.get("no_subs", False) + + # Naloži obstoječi analysis + analysis_path = OUTPUTS_DIR / f"{job_id}.analysis.json" + if not analysis_path.exists(): + raise HTTPException(500, "Analysis manjka — re-uplad pesmi") + + analysis = json.loads(analysis_path.read_text()) + + # Posodobi clip range + analysis["clip_range"] = { + "start": payload.start, + "end": payload.end, + "duration": duration, + "reason": f"USER EDIT: ročno popravljen clip {payload.start:.1f}-{payload.end:.1f}s", + "source": "user_edit", + } + + # Če uporabnik popravil napise, override segmenti + if payload.custom_segments: + # Ohrani originalni transcript ampak shrani custom segments za SRT + analysis["custom_subtitle_segments"] = payload.custom_segments + + analysis_path.write_text(json.dumps(analysis, indent=2, ensure_ascii=False)) + + # Re-queue job v processing (worker ga bo obdelal) + update_job( + job_id, + status="queued", + no_subs=no_subs, + custom_clip=True, # flag da preskoči Soniox + Claude + current_step="V vrsti za recut", + error=None, + chorus_error=None, + ) + + # Briši stari output + out_mp4 = OUTPUTS_DIR / f"{job_id}.mp4" + if out_mp4.exists(): + out_mp4.unlink() + for ext in ("srt", "ass"): + p = OUTPUTS_DIR / f"{job_id}.subtitles.{ext}" + if p.exists(): + p.unlink() + + return { + "status": "queued", + "job_id": job_id, + "start": payload.start, + "end": payload.end, + "duration": duration, + } diff --git a/scripts/analyze.py b/scripts/analyze.py index c356eed..d134762 100644 --- a/scripts/analyze.py +++ b/scripts/analyze.py @@ -1453,7 +1453,7 @@ PROSIM: | Pesem | Refren (kar ide v reel) | Občutek | |---|---|---| | **BRAJDE** (FIRBCI) | "Ajmo Janezi! Pejd' greva, ajde, na traktorju od Majde, noben naju ne najde, ko peljem te v brajde. Da v senci hladni vžgem te po tazadn'i, ko s tebe trgam cunje, Gucci, Louis Vuitton!" | 2x ponovitev z "Ajmo Janezi" intro = 28s | - | **CVETELE SO MALINE** (Avsenik) | "Naj veter zdaj ponese moje sanje čez del neba, tja, kjer je ona doma. Zašepetaj ji bog tja pod kostanje, da sanjam še, kar sva nekoč oba." | PRVI nastop, ~15s, **NE OUTRO** | + | **CVETELE SO MALINE** (Avsenik) | "Naj veter zdaj ponese moje sanje čez del neba, tja, kjer je ona doma. Zašepetaj ji bog tja pod kostanje, da sanjam še, kar sva nekoč oba." | PRVI nastop, ~15s, **VKLJUČI vse do konca "oba" (zadnja beseda IZPETO)** — če je instrumental break za besedo "oba", clip še VEDNO mora vključiti TO BESEDO + 0.5s po njej (NE odreži pred "oba"!) | | **ENA BOLHA ZA POMOČ** (Avsenik) | "Kam se ti tako mudi, punca, voda ne gori, daj počakaj še malo! Mlada ledi, k' si in frej, dej se z fanti mal' igrej, živeti je treba zdej-ej-ej. Hvala, lupčka, lahko noč, ena bolha za pomoč in užiVAJ življenje. Dobro jutro, dober dan, saj ljubezen pač ni plan, in užiVAJ samski stan." | Dolg refren, ~30s | | **ŽENA ME TEPE** (Avsenik) | "Žena me tepe, mi prazni žepe, da vidi, kje in s kom sem bil. Jaz pa razlagam, da ne vem, da nisem kriv, da šel naravnost sem domov, pa se izgubil. Žena me tepe, obrača žepe, preverja, kje in s kom sem pil. Saj bi ji rekel, naj se že enkrat ustav', pa vem, da ima po svoje prav." | 2 vrstici refrena ~30s | | **STISN SE K MEN** (Stil) | "Stisni se k meni, tak ni noben. Ko bova stara, bova spala, do takrat pa boogie-woogie žgala, ne? Ne hecam se, ko se zaljubim, ljubim, kot da zadnjič je." | 2x ponovitev = ~30s | diff --git a/templates/index.html b/templates/index.html index 6c0566c..2c37a9b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -923,6 +923,7 @@ if (job.status === "done") { actions.push(``); actions.push(``); + actions.push(``); } actions.push(``); @@ -971,6 +972,8 @@ window.open(`/api/download/${id}`); } else if (action === "preview") { previewJob(id, title); + } else if (action === "edit") { + openEditModal(id, title); } else if (action === "delete") { deleteJob(id); } @@ -982,6 +985,188 @@ refreshJobs(); } + // ─── EDIT MODAL ───────────────────────────────────── + async function openEditModal(jobId, title) { + // Fetch transcript + clip range + let data; + try { + const res = await fetch(`/api/transcript/${jobId}`); + if (!res.ok) { + alert("Napaka: ne morem naložiti transkripta"); + return; + } + data = await res.json(); + } catch (e) { + alert("Napaka: " + e.message); + return; + } + + const startInit = data.clip_range?.start || 0; + const endInit = data.clip_range?.end || (startInit + 30); + const videoDuration = data.video_duration || endInit + 60; + const segments = data.segments || []; + + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + document.body.style.overflow = "hidden"; + + // ESC key + const escHandler = (e) => { + if (e.key === "Escape") { + closeModal(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); + overlay.querySelector(".modal-close").addEventListener("click", closeModal); + + // Slider handlers + const video = document.getElementById("edit-video"); + const startSlider = document.getElementById("edit-start"); + const endSlider = document.getElementById("edit-end"); + const startVal = document.getElementById("edit-start-val"); + const endVal = document.getElementById("edit-end-val"); + const durVal = document.getElementById("edit-duration"); + + function updateUI() { + const s = parseFloat(startSlider.value); + const e = parseFloat(endSlider.value); + startVal.textContent = s.toFixed(1) + "s"; + endVal.textContent = e.toFixed(1) + "s"; + durVal.textContent = (e - s).toFixed(1) + "s"; + } + + startSlider.addEventListener("input", () => { + if (parseFloat(startSlider.value) >= parseFloat(endSlider.value)) { + startSlider.value = parseFloat(endSlider.value) - 0.5; + } + updateUI(); + if (video) video.currentTime = parseFloat(startSlider.value); + }); + endSlider.addEventListener("input", () => { + if (parseFloat(endSlider.value) <= parseFloat(startSlider.value)) { + endSlider.value = parseFloat(startSlider.value) + 0.5; + } + updateUI(); + if (video) video.currentTime = parseFloat(endSlider.value); + }); + + window.seekEditVideo = function(which) { + if (!video) return; + const t = which === "start" ? parseFloat(startSlider.value) : parseFloat(endSlider.value); + video.currentTime = t; + if (which === "start") video.play(); + }; + + // Save button + document.getElementById("edit-save-btn").addEventListener("click", async () => { + const start = parseFloat(startSlider.value); + const end = parseFloat(endSlider.value); + if (end - start < 5) { + alert("Trajanje mora biti vsaj 5s"); + return; + } + if (end - start > 60) { + alert("Trajanje največ 60s"); + return; + } + + // Zberi popravljene segmente + const segInputs = document.querySelectorAll("#edit-segments input"); + const customSegments = []; + let hasChanges = false; + segInputs.forEach(inp => { + const orig = inp.dataset.orig.replace(/\\n/g, ' ').trim(); + const newText = inp.value.trim(); + if (orig !== newText) hasChanges = true; + customSegments.push({ + start: parseFloat(inp.dataset.start), + end: parseFloat(inp.dataset.end), + text: newText, + }); + }); + + const status = document.getElementById("edit-status"); + status.textContent = "⏳ Pošiljam zahtevo..."; + document.getElementById("edit-save-btn").disabled = true; + + try { + const body = { start, end }; + if (hasChanges) body.custom_segments = customSegments; + + const res = await fetch(`/api/jobs/${jobId}/recut`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.json(); + status.textContent = "❌ " + (err.detail || "Napaka"); + document.getElementById("edit-save-btn").disabled = false; + return; + } + + status.textContent = "✅ Re-render v vrsti! Bo gotov v ~30s."; + setTimeout(() => { + closeModal(); + refreshJobs(); + }, 1500); + } catch (e) { + status.textContent = "❌ Napaka: " + e.message; + document.getElementById("edit-save-btn").disabled = false; + } + }); + } + function previewJob(id, title) { // Odpre velik modal z reel videom const overlay = document.createElement("div");