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; }