From a4374fe96aa7cf9801b4829e14e23942bd4757a2 Mon Sep 17 00:00:00 2001 From: bernd Date: Tue, 29 Sep 2026 10:08:24 +0200 Subject: [PATCH] fortschritt beim schneiden anzeigen --- editor.py | 222 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 174 insertions(+), 48 deletions(-) diff --git a/editor.py b/editor.py index 874fe41..25d3753 100644 --- a/editor.py +++ b/editor.py @@ -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; }
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.
+
Verarbeitung läuft …0 %
@@ -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(); @@ -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/") +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__":