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