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:
+196
-4
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user