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:
@@ -923,6 +923,7 @@
|
||||
if (job.status === "done") {
|
||||
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="edit" data-id="${job.id}">✏️ Edit</button>`);
|
||||
}
|
||||
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
|
||||
|
||||
@@ -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 = `
|
||||
<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) {
|
||||
// Odpre velik modal z reel videom
|
||||
const overlay = document.createElement("div");
|
||||
|
||||
Reference in New Issue
Block a user