fortschritt beim schneiden anzeigen
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user