Edit feature: slider + napis edit + recut endpoint
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).
This commit is contained in:
parent
f99574daff
commit
7cb4302dcd
200
app/main.py
200
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:
|
if words_in_clip and len(words_in_clip) >= 2:
|
||||||
# Group besede v chunke z max trajanjem MAX_CHUNK_DURATION
|
# 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 = []
|
chunks = []
|
||||||
current_chunk = [words_in_clip[0]]
|
current_chunk = [words_in_clip[0]]
|
||||||
for w in words_in_clip[1:]:
|
for w in words_in_clip[1:]:
|
||||||
chunk_start_time = current_chunk[0]["start"]
|
chunk_start_time = current_chunk[0]["start"]
|
||||||
chunk_dur_so_far = w["end"] - chunk_start_time
|
chunk_dur_so_far = w["end"] - chunk_start_time
|
||||||
|
|
||||||
|
# Bi začeli nov chunk?
|
||||||
if chunk_dur_so_far > MAX_CHUNK_DURATION:
|
if chunk_dur_so_far > MAX_CHUNK_DURATION:
|
||||||
chunks.append(current_chunk)
|
# Preveri ali bi current_chunk ostal "siroto kratek"
|
||||||
current_chunk = [w]
|
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:
|
else:
|
||||||
current_chunk.append(w)
|
current_chunk.append(w)
|
||||||
if current_chunk:
|
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)
|
# Generiraj SRT iz chunks (z dejanskimi word timestampi)
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
@ -490,7 +512,50 @@ def process_job(job_id):
|
|||||||
print(f"⚠️ ACR error: {e}", flush=True)
|
print(f"⚠️ ACR error: {e}", flush=True)
|
||||||
|
|
||||||
# ── 2. Smart analysis (če auto_chorus) ──────────────────────────
|
# ── 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)")
|
update_job(job_id, current_step="Analiza pesmi (transkript + energija)")
|
||||||
analysis_path = OUTPUT_DIR / f"{job_id}.analysis.json"
|
analysis_path = OUTPUT_DIR / f"{job_id}.analysis.json"
|
||||||
cmd = [
|
cmd = [
|
||||||
@ -1164,3 +1229,130 @@ async def delete_job(job_id: str, user: str = Depends(check_auth)):
|
|||||||
Path(p).unlink(missing_ok=True)
|
Path(p).unlink(missing_ok=True)
|
||||||
job_path(job_id).unlink(missing_ok=True)
|
job_path(job_id).unlink(missing_ok=True)
|
||||||
return {"deleted": job_id}
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@ -1453,7 +1453,7 @@ PROSIM:
|
|||||||
| Pesem | Refren (kar ide v reel) | Občutek |
|
| 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 |
|
| **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 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 |
|
| **Ž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 |
|
| **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 |
|
||||||
|
|||||||
@ -923,6 +923,7 @@
|
|||||||
if (job.status === "done") {
|
if (job.status === "done") {
|
||||||
actions.push(`<button class="small" data-action="download" data-id="${job.id}">⬇ Download</button>`);
|
actions.push(`<button class="small" data-action="download" data-id="${job.id}">⬇ Download</button>`);
|
||||||
actions.push(`<button class="small ghost" data-action="preview" data-id="${job.id}">▶ Preview</button>`);
|
actions.push(`<button class="small ghost" data-action="preview" data-id="${job.id}">▶ Preview</button>`);
|
||||||
|
actions.push(`<button class="small ghost" data-action="edit" data-id="${job.id}">✏️ Edit</button>`);
|
||||||
}
|
}
|
||||||
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
|
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
|
||||||
|
|
||||||
@ -971,6 +972,8 @@
|
|||||||
window.open(`/api/download/${id}`);
|
window.open(`/api/download/${id}`);
|
||||||
} else if (action === "preview") {
|
} else if (action === "preview") {
|
||||||
previewJob(id, title);
|
previewJob(id, title);
|
||||||
|
} else if (action === "edit") {
|
||||||
|
openEditModal(id, title);
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
deleteJob(id);
|
deleteJob(id);
|
||||||
}
|
}
|
||||||
@ -982,6 +985,188 @@
|
|||||||
refreshJobs();
|
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 = `
|
||||||
|
<div class="modal-content edit-modal" onclick="event.stopPropagation()" style="max-width:900px;">
|
||||||
|
<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}" controls preload="metadata" style="width:100%; max-height:50vh; background:#000;"></video>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:14px; margin-top:14px;">
|
||||||
|
<div>
|
||||||
|
<label style="display:flex; justify-content:space-between; font-size:13px;">
|
||||||
|
<span>Začetek</span>
|
||||||
|
<span><b id="edit-start-val">${startInit.toFixed(1)}s</b> · trajanje: <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b></span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="edit-start" min="0" max="${videoDuration.toFixed(1)}" step="0.1" value="${startInit.toFixed(1)}" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:flex; justify-content:space-between; font-size:13px;">
|
||||||
|
<span>Konec</span>
|
||||||
|
<span id="edit-end-val">${endInit.toFixed(1)}s</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="edit-end" min="0" max="${videoDuration.toFixed(1)}" step="0.1" value="${endInit.toFixed(1)}" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="small" onclick="seekEditVideo('start')">▶ Predvajaj od začetka</button>
|
||||||
|
<button class="small ghost" onclick="seekEditVideo('end')">↪ Skoči na konec</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="margin-top:8px;">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div id="edit-status" style="margin-top:10px; font-size:12px; color:var(--muted);"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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) {
|
function previewJob(id, title) {
|
||||||
// Odpre velik modal z reel videom
|
// Odpre velik modal z reel videom
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user