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 subprocess
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
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"))
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
JOBS = {}
JOBS_LOCK = threading.Lock()
# ---------------------------------------------------------------------------
# Application
@@ -213,7 +219,7 @@ def create_waveform(filename):
return None
def create_finished(filename, start, end):
def create_finished(filename, start, end, progress_callback=None):
source = source_path(filename)
if source is None:
raise ValueError("Aufnahme nicht gefunden")
@@ -237,35 +243,98 @@ def create_finished(filename, start, end):
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
destination = finished_path(filename)
temp = FINISHED_DIR / f".{filename}.tmp.mp3"
selected_duration = end - start
result = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-y",
"-fflags", "+genpts",
"-ss", f"{start:.3f}",
"-i", str(source),
"-t", f"{end - start:.3f}",
"-map", "0:a:0",
"-vn", "-sn", "-dn",
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:a", "libmp3lame",
"-b:a", "128k",
"-avoid_negative_ts", "make_zero",
str(temp),
],
capture_output=True, text=True, timeout=1800,
)
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-fflags", "+genpts",
"-ss", f"{start:.3f}",
"-i", str(source),
"-t", f"{selected_duration:.3f}",
"-map", "0:a:0",
"-vn", "-sn", "-dn",
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:a", "libmp3lame",
"-b:a", "128k",
"-avoid_negative_ts", "make_zero",
"-progress", "pipe:1",
"-nostats",
str(temp),
]
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:
temp.unlink(missing_ok=True)
except OSError:
pass
print(f"[edit] ffmpeg failed for {filename}: {result.stderr.strip()}", flush=True)
raise RuntimeError("Schneiden fehlgeschlagen")
raise
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.ok { display:block; background:#173d24; color:#b8f5c9; }
.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; }
@media(max-width:700px) {
body { padding:12px; }
@@ -383,6 +457,7 @@ button:disabled { opacity:.45; cursor:not-allowed; }
<button class="button danger" onclick="deleteOriginal()">🗑 Originalaufnahme löschen</button>
</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>
</div>
@@ -676,22 +751,57 @@ async function deleteOriginal() {
async function trim() {
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;
const btn=document.getElementById('trimButton'); btn.disabled=true; audio.pause();
msg('Aufnahme wird geschnitten …',true);
const btn=document.getElementById('trimButton');
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 {
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') || '';
let d;
if (contentType.includes('application/json')) {
d = await r.json();
} else {
const text = await r.text();
throw new Error(`Serverfehler HTTP ${r.status}: ${text.slice(0, 200)}`);
}
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Schneiden (HTTP ${r.status})`);
msg('Fertige Aufnahme erfolgreich erstellt.',true);
setTimeout(()=>location.reload(),700);
} catch(e) { msg(e.message,false); btn.disabled=false; }
if (contentType.includes('application/json')) d = await r.json();
else throw new Error(`Serverfehler HTTP ${r.status}: ${(await r.text()).slice(0,200)}`);
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Starten (HTTP ${r.status})`);
const jobId=d.job_id;
const poll=async()=>{
const sr=await fetch('/api/edit/status/'+encodeURIComponent(jobId), {cache:'no-store'});
const sd=await sr.json();
if(!sr.ok || !sd.success) throw new Error(sd.error || `Statusfehler HTTP ${sr.status}`);
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();
</script>
@@ -957,20 +1067,36 @@ def api_file():
def api_edit():
data = request.get_json(silent=True) or {}
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:
destination, duration = create_finished(filename, data.get("start"), data.get("end"))
return jsonify({
"success": True,
"filename": destination.name,
"duration": duration,
"duration_formatted": format_duration(duration),
})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
print(f"[edit] failed for {filename}: {exc}", flush=True)
return jsonify({"success": False, "error": "Schneiden fehlgeschlagen"}), 500
start_f = float(start)
end_f = float(end)
except (TypeError, ValueError):
return jsonify({"success": False, "error": "Ungültige Zeitangabe"}), 400
if end_f <= start_f or end_f - start_f < 0.5:
return jsonify({"success": False, "error": "Ungültiger Auswahlbereich"}), 400
job_id = uuid.uuid4().hex
with JOBS_LOCK:
JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename,
"created_at":time.time()}
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__":