editor.py hinzugefügt

This commit is contained in:
2026-09-29 07:43:00 +02:00
parent eb1d438883
commit b252b83eb6
+568
View File
@@ -0,0 +1,568 @@
import os
import subprocess
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings"))
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"))
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
app = Flask(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_datetime(timestamp):
return datetime.fromtimestamp(timestamp, ZoneInfo("Europe/Berlin")).strftime(
"%d.%m.%Y %H:%M:%S"
)
def format_size(size):
if size < 1024:
return f"{size} B"
if size < 1024 ** 2:
return f"{size / 1024:.1f} KB"
if size < 1024 ** 3:
return f"{size / 1024 ** 2:.1f} MB"
return f"{size / 1024 ** 3:.2f} GB"
def format_duration(seconds):
if seconds is None:
return "–"
seconds = int(seconds)
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
if hours:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
def safe_filename(filename):
if not filename or Path(filename).name != filename:
return None
if not filename.lower().endswith(".mp3"):
return None
return filename
def source_path(filename):
filename = safe_filename(filename)
if filename is None:
return None
path = RECORDINGS_DIR / filename
return path if path.is_file() else None
def finished_path(filename):
filename = safe_filename(filename)
if filename is None:
return None
return FINISHED_DIR / filename
def get_duration(path):
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
return float(result.stdout.strip())
except (OSError, ValueError, subprocess.SubprocessError):
return None
def waveform_path(filename):
return WAVEFORMS_DIR / f"{filename}.png"
def create_waveform(filename):
source = source_path(filename)
if source is None:
return None
output = waveform_path(filename)
WAVEFORMS_DIR.mkdir(parents=True, exist_ok=True)
try:
if output.is_file() and output.stat().st_mtime >= source.stat().st_mtime:
return output
except FileNotFoundError:
pass
temp = output.with_suffix(".tmp.png")
try:
result = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source),
"-filter_complex",
"aformat=channel_layouts=mono,showwavespic=s=2400x240:split_channels=0",
"-frames:v", "1",
str(temp),
],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0 or not temp.is_file():
print(f"[waveform] failed for {filename}: {result.stderr.strip()}", flush=True)
return None
temp.replace(output)
return output
except Exception as exc:
print(f"[waveform] failed for {filename}: {exc}", flush=True)
try:
temp.unlink(missing_ok=True)
except OSError:
pass
return None
def create_finished(filename, start, end):
source = source_path(filename)
if source is None:
raise ValueError("Aufnahme nicht gefunden")
try:
start = float(start)
end = float(end)
except (TypeError, ValueError):
raise ValueError("Ungültige Zeitangabe")
duration = get_duration(source)
if duration is None:
raise ValueError("Aufnahmedauer konnte nicht ermittelt werden")
if start < 0 or end > duration or end <= start:
raise ValueError("Ungültiger Auswahlbereich")
if end - start < 0.5:
raise ValueError("Der ausgewählte Bereich ist zu kurz")
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
destination = finished_path(filename)
temp = FINISHED_DIR / f".{filename}.tmp.mp3"
result = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-y",
"-i", str(source),
"-ss", f"{start:.3f}",
"-t", f"{end - start:.3f}",
"-c:a", "libmp3lame",
"-b:a", "128k",
str(temp),
],
capture_output=True, text=True, timeout=1800,
)
if result.returncode != 0 or not temp.is_file():
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")
temp.replace(destination)
return destination, get_duration(destination)
# ---------------------------------------------------------------------------
# HTML
# ---------------------------------------------------------------------------
EDITOR_HTML = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Aufnahme bearbeiten</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; font-size:28px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.editor { background:#181818; border:1px solid #333; border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:#0b0b0b; border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
.wave-wrap img { width:100%; height:100%; object-fit:fill; display:block; opacity:.9; }
.selection { position:absolute; top:0; bottom:0; background:rgba(80,150,255,.16); border-left:2px solid #fff; border-right:2px solid #fff; pointer-events:none; }
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
button:hover, a.button:hover { background:#444; }
button.primary { background:#1769aa; }
button.danger { background:#8b0000; }
button:disabled { opacity:.45; cursor:not-allowed; }
.position { margin-left:auto; color:#ccc; font-variant-numeric:tabular-nums; }
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
.value { background:#222; border-radius:6px; padding:12px; }
.value span { display:block; color:#999; font-size:13px; margin-bottom:5px; }
.value strong { font-size:20px; font-variant-numeric:tabular-nums; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
.info { margin-top:16px; color:#999; font-size:14px; }
.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; }
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
@media(max-width:700px) {
body { padding:12px; }
.editor { padding:12px; }
.wave-wrap { height:180px; }
.values { grid-template-columns:1fr; }
.position { width:100%; margin-left:0; }
button, a.button { flex:1 1 auto; }
}
</style>
</head>
<body>
<div class="container">
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
<h1>Aufnahme bearbeiten</h1>
<div class="subtitle">{{ filename }} · Originaldauer {{ duration }}</div>
<div class="editor">
<div class="wave-wrap" id="wave">
<img src="{{ waveform_url }}" alt="Wellenform">
<div class="selection" id="selection"></div>
<div class="handle" id="startHandle" title="Anfang verschieben"></div>
<div class="handle" id="endHandle" title="Ende verschieben"></div>
</div>
<div class="time-row">
<span>00:00:00</span>
<span>{{ duration }}</span>
</div>
<audio id="audio" controls preload="metadata" style="width:100%;margin-top:18px">
<source src="{{ audio_url }}" type="audio/mpeg">
</audio>
<div class="controls">
<button onclick="jump(-10)">⏮ 10 s</button>
<button onclick="jump(10)">10 s ⏭</button>
<button onclick="playSelection()">▶ Auswahl abspielen</button>
<button onclick="playFromStart()">▶ Ab Auswahlstart</button>
<span class="position" id="position">00:00:00</span>
</div>
<div class="values">
<div class="value"><span>Anfang</span><strong id="startValue">00:00:00</strong></div>
<div class="value"><span>Ende</span><strong id="endValue">00:00:00</strong></div>
<div class="value"><span>Neue Dauer</span><strong id="keepValue">00:00:00</strong></div>
</div>
<div class="actions">
<button class="primary" id="trimButton" onclick="trim()">✂ Fertige Aufnahme erstellen</button>
<a class="button" href="{{ audio_url }}" download>Original herunterladen</a>
</div>
<div class="info">Die erste Version der Aufnahme wird als Original auf dem Server behalten. Weitere Schnitte sind möglich und können jederzeit auf das Original zurückgesetzt werden.</div>
<div id="message" class="message"></div>
</div>
</div>
<script>
const duration = {{ duration_seconds|tojson }};
const filename = {{ filename|tojson }};
let start = 0;
let end = duration;
let dragging = null;
const wave = document.getElementById('wave');
const startHandle = document.getElementById('startHandle');
const endHandle = document.getElementById('endHandle');
const selection = document.getElementById('selection');
const audio = document.getElementById('audio');
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function xToTime(clientX) {
const r = wave.getBoundingClientRect();
return Math.max(0, Math.min(duration, ((clientX-r.left)/r.width)*duration));
}
function render() {
const a = (start/duration)*100;
const b = (end/duration)*100;
startHandle.style.left = a+'%';
endHandle.style.left = b+'%';
selection.style.left = a+'%';
selection.style.width = (b-a)+'%';
document.getElementById('startValue').textContent = fmt(start);
document.getElementById('endValue').textContent = fmt(end);
document.getElementById('keepValue').textContent = fmt(end-start);
}
function beginDrag(which, e) {
e.preventDefault();
dragging = which;
document.body.style.cursor='ew-resize';
}
startHandle.addEventListener('pointerdown', e => beginDrag('start',e));
endHandle.addEventListener('pointerdown', e => beginDrag('end',e));
document.addEventListener('pointermove', e => {
if (!dragging) return;
const t = xToTime(e.clientX);
if (dragging === 'start') start = Math.min(t, end-0.5);
else end = Math.max(t, start+0.5);
render();
});
document.addEventListener('pointerup', () => { dragging=null; document.body.style.cursor=''; });
wave.addEventListener('pointerdown', e => {
if (e.target === startHandle || e.target === endHandle) return;
const t=xToTime(e.clientX);
audio.currentTime = Math.max(0, Math.min(duration,t));
});
audio.addEventListener('timeupdate', () => {
document.getElementById('position').textContent=fmt(audio.currentTime);
if (audio.currentTime >= end && !audio.paused) audio.pause();
});
function jump(sec) { audio.currentTime=Math.max(0,Math.min(duration,audio.currentTime+sec)); }
function playFromStart() { audio.currentTime=start; audio.play(); }
function playSelection() { audio.currentTime=start; audio.play(); }
function msg(text, ok) {
const el=document.getElementById('message'); el.textContent=text; el.className='message '+(ok?'ok':'error');
}
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);
try {
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end})});
const d=await r.json();
if(!r.ok || !d.success) throw new Error(d.error || 'Fehler beim Schneiden');
msg('Fertige Aufnahme erfolgreich erstellt.',true);
setTimeout(()=>location.reload(),700);
} catch(e) { msg(e.message,false); btn.disabled=false; }
}
render();
</script>
</body>
</html>
"""
INDEX_HTML = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Editor</title>
<style>
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
table { width:100%; border-collapse:collapse; }
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
th { color:#aaa; font-weight:normal; }
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:#333; color:#fff; cursor:pointer; text-decoration:none; }
.button:hover { background:#444; }
.empty { color:#999; padding:15px 0; }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
</style>
</head>
<body>
<div class="container">
<h1>Event Audio – Editor</h1>
<div class="subtitle">Fertige Aufnahmen aus {{ recordings_dir }}</div>
<div class="section">
<h2>Aufnahmen bearbeiten</h2>
{% if recordings %}
<table>
<thead><tr><th>Aufnahme</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td><a class="button" href="{{ url_for('edit_recording', filename=item.filename) }}">✂ Bearbeiten</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Keine fertigen Aufnahmen vorhanden.</div>{% endif %}
</div>
<div class="section">
<h2>Bearbeitete Aufnahmen</h2>
{% if finished %}
<table>
<thead><tr><th>Datei</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in finished %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td><a class="button" href="{{ url_for('finished_file', filename=item.filename) }}">▶ Abspielen / Download</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Noch keine bearbeiteten Aufnahmen vorhanden.</div>{% endif %}
</div>
</div>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
recordings = []
for path in RECORDINGS_DIR.glob("*.mp3"):
try:
stat = path.stat()
recordings.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
finished = []
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
finished.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["mtime"], reverse=True)
return render_template_string(
INDEX_HTML,
recordings=recordings,
finished=finished,
recordings_dir=str(RECORDINGS_DIR),
)
@app.route("/edit/<path:filename>")
def edit_recording(filename):
path = source_path(filename)
if path is None:
abort(404)
duration = get_duration(path)
if duration is None:
abort(500, description="Aufnahmedauer konnte nicht ermittelt werden")
waveform = create_waveform(filename)
if waveform is None:
abort(500, description="Wellenform konnte nicht erzeugt werden")
return render_template_string(
EDITOR_HTML,
filename=filename,
duration=format_duration(duration),
duration_seconds=duration,
waveform_url=url_for("waveform", filename=filename),
audio_url=url_for("recording", filename=filename),
)
@app.route("/waveforms/<path:filename>")
def waveform(filename):
if source_path(filename) is None:
abort(404)
output = create_waveform(filename)
if output is None:
abort(404)
return send_from_directory(WAVEFORMS_DIR, output.name, mimetype="image/png")
@app.route("/recordings/<path:filename>")
def recording(filename):
path = source_path(filename)
if path is None:
abort(404)
return send_from_directory(RECORDINGS_DIR, path.name, mimetype="audio/mpeg")
@app.route("/finished/<path:filename>")
def finished_file(filename):
path = finished_path(filename)
if path is None or not path.is_file():
abort(404)
return send_from_directory(FINISHED_DIR, path.name, mimetype="audio/mpeg", as_attachment=False)
@app.route("/api/edit", methods=["POST"])
def api_edit():
data = request.get_json(silent=True) or {}
filename = data.get("filename")
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
if __name__ == "__main__":
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
app.run(host="0.0.0.0", port=WEB_PORT)