fortschritt beim schneiden anzeigen

This commit is contained in:
2026-09-29 10:08:24 +02:00
parent fd83f93da0
commit a4374fe96a
+174 -48
View File
@@ -1,5 +1,8 @@
import os import os
import subprocess import subprocess
import threading
import time
import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -16,6 +19,9 @@ FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished"))
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms")) WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
WEB_PORT = int(os.environ.get("WEB_PORT", "8080")) WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
JOBS = {}
JOBS_LOCK = threading.Lock()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Application # Application
@@ -213,7 +219,7 @@ def create_waveform(filename):
return None return None
def create_finished(filename, start, end): def create_finished(filename, start, end, progress_callback=None):
source = source_path(filename) source = source_path(filename)
if source is None: if source is None:
raise ValueError("Aufnahme nicht gefunden") raise ValueError("Aufnahme nicht gefunden")
@@ -237,35 +243,98 @@ def create_finished(filename, start, end):
FINISHED_DIR.mkdir(parents=True, exist_ok=True) FINISHED_DIR.mkdir(parents=True, exist_ok=True)
destination = finished_path(filename) destination = finished_path(filename)
temp = FINISHED_DIR / f".{filename}.tmp.mp3" temp = FINISHED_DIR / f".{filename}.tmp.mp3"
selected_duration = end - start
result = subprocess.run( cmd = [
[ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-y", "-fflags", "+genpts",
"-fflags", "+genpts", "-ss", f"{start:.3f}",
"-ss", f"{start:.3f}", "-i", str(source),
"-i", str(source), "-t", f"{selected_duration:.3f}",
"-t", f"{end - start:.3f}", "-map", "0:a:0",
"-map", "0:a:0", "-vn", "-sn", "-dn",
"-vn", "-sn", "-dn", "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", "libmp3lame",
"-c:a", "libmp3lame", "-b:a", "128k",
"-b:a", "128k", "-avoid_negative_ts", "make_zero",
"-avoid_negative_ts", "make_zero", "-progress", "pipe:1",
str(temp), "-nostats",
], str(temp),
capture_output=True, text=True, timeout=1800, ]
)
if result.returncode != 0 or not temp.is_file(): try:
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1
)
stderr_lines = []
stderr_thread = threading.Thread(
target=lambda: stderr_lines.extend(process.stderr.readlines()),
daemon=True,
)
stderr_thread.start()
last_progress = -1
while True:
line = process.stdout.readline()
if not line:
if process.poll() is not None:
break
time.sleep(0.05)
continue
line = line.strip()
if line.startswith("out_time_us="):
try:
out_us = int(line.split("=", 1)[1])
progress = max(0.0, min(100.0, out_us / 1_000_000 / selected_duration * 100.0))
if progress_callback and (progress - last_progress >= 0.2 or progress >= 100):
progress_callback(progress)
last_progress = progress
except (ValueError, ZeroDivisionError):
pass
returncode = process.wait()
stderr_thread.join(timeout=2)
stderr = "".join(stderr_lines).strip()
if returncode != 0 or not temp.is_file():
try:
temp.unlink(missing_ok=True)
except OSError:
pass
print(f"[edit] ffmpeg failed for {filename}: {stderr}", flush=True)
raise RuntimeError("Schneiden fehlgeschlagen" + (f": {stderr[-500]}" if stderr else ""))
temp.replace(destination)
if progress_callback:
progress_callback(100.0)
return destination, get_duration(destination)
except Exception:
try: try:
temp.unlink(missing_ok=True) temp.unlink(missing_ok=True)
except OSError: except OSError:
pass pass
print(f"[edit] ffmpeg failed for {filename}: {result.stderr.strip()}", flush=True) raise
raise RuntimeError("Schneiden fehlgeschlagen")
temp.replace(destination)
return destination, get_duration(destination) def run_edit_job(job_id, filename, start, end):
def update(**values):
with JOBS_LOCK:
job = JOBS.get(job_id)
if job:
job.update(values)
update(status="running", progress=0.0)
try:
destination, duration = create_finished(
filename, start, end,
progress_callback=lambda value: update(progress=value),
)
update(status="done", progress=100.0, filename=destination.name,
duration=duration, finished_at=time.time())
except Exception as exc:
print(f"[edit] job {job_id} failed for {filename}: {exc}", flush=True)
update(status="error", error=str(exc), finished_at=time.time())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -317,6 +386,11 @@ button:disabled { opacity:.45; cursor:not-allowed; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; } .message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; } .message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; } .message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
.progress-box { display:none; margin-top:16px; }
.progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:#1769aa; transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:#aaa; font-size:14px; font-variant-numeric:tabular-nums; }
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; } .back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
@media(max-width:700px) { @media(max-width:700px) {
body { padding:12px; } body { padding:12px; }
@@ -383,6 +457,7 @@ button:disabled { opacity:.45; cursor:not-allowed; }
<button class="button danger" onclick="deleteOriginal()">🗑 Originalaufnahme löschen</button> <button class="button danger" onclick="deleteOriginal()">🗑 Originalaufnahme löschen</button>
</div> </div>
<div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert. Das Original kann hier bewusst gelöscht werden; bereits erstellte fertige Aufnahmen bleiben dabei erhalten.</div> <div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert. Das Original kann hier bewusst gelöscht werden; bereits erstellte fertige Aufnahmen bleiben dabei erhalten.</div>
<div class="progress-box" id="progressBox"><div class="progress-track"><div class="progress-bar" id="progressBar"></div></div><div class="progress-info"><span id="progressText">Verarbeitung läuft …</span><span id="progressPercent">0 %</span></div></div>
<div id="message" class="message"></div> <div id="message" class="message"></div>
</div> </div>
</div> </div>
@@ -676,22 +751,57 @@ async function deleteOriginal() {
async function trim() { async function trim() {
if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false); if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false);
if (!confirm(`Aufnahme auf ${fmt(start)} bis ${fmt(end)} kürzen?`)) return; if (!confirm(`Aufnahme auf ${fmt(start)} bis ${fmt(end)} kürzen?`)) return;
const btn=document.getElementById('trimButton'); btn.disabled=true; audio.pause(); const btn=document.getElementById('trimButton');
msg('Aufnahme wird geschnitten …',true); const progressBox=document.getElementById('progressBox');
const progressBar=document.getElementById('progressBar');
const progressText=document.getElementById('progressText');
const progressPercent=document.getElementById('progressPercent');
btn.disabled=true;
audio.pause();
progressBox.classList.add('active');
progressBar.style.width='0%';
progressPercent.textContent='0 %';
progressText.textContent='Verarbeitung wird gestartet …';
msg('Aufnahme wird im Hintergrund geschnitten …',true);
try { try {
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end})}); const r=await fetch('/api/edit', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({filename,start,end})
});
const contentType = r.headers.get('content-type') || ''; const contentType = r.headers.get('content-type') || '';
let d; let d;
if (contentType.includes('application/json')) { if (contentType.includes('application/json')) d = await r.json();
d = await r.json(); else throw new Error(`Serverfehler HTTP ${r.status}: ${(await r.text()).slice(0,200)}`);
} else { if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Starten (HTTP ${r.status})`);
const text = await r.text();
throw new Error(`Serverfehler HTTP ${r.status}: ${text.slice(0, 200)}`); const jobId=d.job_id;
} const poll=async()=>{
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Schneiden (HTTP ${r.status})`); const sr=await fetch('/api/edit/status/'+encodeURIComponent(jobId), {cache:'no-store'});
msg('Fertige Aufnahme erfolgreich erstellt.',true); const sd=await sr.json();
setTimeout(()=>location.reload(),700); if(!sr.ok || !sd.success) throw new Error(sd.error || `Statusfehler HTTP ${sr.status}`);
} catch(e) { msg(e.message,false); btn.disabled=false; } const percent=Math.max(0,Math.min(100,sd.progress || 0));
progressBar.style.width=percent+'%';
progressPercent.textContent=Math.round(percent)+' %';
if(sd.status==='running') {
progressText.textContent='Verarbeitung läuft …';
setTimeout(poll,700);
} else if(sd.status==='done') {
progressBar.style.width='100%';
progressPercent.textContent='100 %';
progressText.textContent='Fertig';
msg('Fertige Aufnahme erfolgreich erstellt.',true);
setTimeout(()=>location.reload(),800);
} else {
throw new Error(sd.error || 'Schneiden fehlgeschlagen');
}
};
poll().catch(e=>{ msg(e.message,false); btn.disabled=false; progressText.textContent='Fehler'; });
} catch(e) {
msg(e.message,false);
btn.disabled=false;
progressBox.classList.remove('active');
}
} }
render(); render();
</script> </script>
@@ -957,20 +1067,36 @@ def api_file():
def api_edit(): def api_edit():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
filename = data.get("filename") filename = data.get("filename")
start = data.get("start")
end = data.get("end")
source = source_path(filename)
if source is None:
return jsonify({"success": False, "error": "Aufnahme nicht gefunden"}), 404
try: try:
destination, duration = create_finished(filename, data.get("start"), data.get("end")) start_f = float(start)
return jsonify({ end_f = float(end)
"success": True, except (TypeError, ValueError):
"filename": destination.name, return jsonify({"success": False, "error": "Ungültige Zeitangabe"}), 400
"duration": duration, if end_f <= start_f or end_f - start_f < 0.5:
"duration_formatted": format_duration(duration), return jsonify({"success": False, "error": "Ungültiger Auswahlbereich"}), 400
})
except ValueError as exc: job_id = uuid.uuid4().hex
return jsonify({"success": False, "error": str(exc)}), 400 with JOBS_LOCK:
except Exception as exc: JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename,
print(f"[edit] failed for {filename}: {exc}", flush=True) "created_at":time.time()}
return jsonify({"success": False, "error": "Schneiden fehlgeschlagen"}), 500 threading.Thread(target=run_edit_job, args=(job_id, filename, start_f, end_f), daemon=True).start()
return jsonify({"success": True, "job_id": job_id}), 202
@app.route("/api/edit/status/<job_id>")
def api_edit_status(job_id):
with JOBS_LOCK:
job = JOBS.get(job_id)
if not job:
return jsonify({"success": False, "error": "Job nicht gefunden"}), 404
result = dict(job)
return jsonify({"success": True, **result})
if __name__ == "__main__": if __name__ == "__main__":