Version 1 OneShot

This commit is contained in:
2026-10-09 18:58:50 +00:00
parent 514c13d184
commit bf0d661eed
27 changed files with 3039 additions and 0 deletions

219
ffwrap/gui/runner.py Normal file
View File

@@ -0,0 +1,219 @@
"""Runs ffmpeg for each queued job, one at a time, via QProcess."""
from __future__ import annotations
import os
from pathlib import Path
from PySide6.QtCore import QObject, QProcess, Signal
from ..command import Options, build_command, format_command, plan_output_path, temp_output_path
from ..tools import FFmpegTools
from .queue_model import Job, Status
def _remove_quietly(path: Path | None) -> None:
if path is None:
return
try:
path.unlink()
except OSError:
pass
class BatchRunner(QObject):
job_changed = Signal(object) # Job
log = Signal(str)
progress = Signal(float) # overall 0..1
finished = Signal(dict) # counts per status
def __init__(self, parent=None):
super().__init__(parent)
self._proc: QProcess | None = None
self._jobs: list[Job] = []
self._pos = 0
self._opts = Options()
self._tools: FFmpegTools | None = None
self._taken: set[str] = set()
self._current: Job | None = None
self._temp: Path | None = None
self._stdout_buf = b""
self._stderr_buf = b""
self._stderr_tail: list[str] = []
self._cancelled = False
@property
def running(self) -> bool:
return self._current is not None
# -- public API ---------------------------------------------------------
def start(self, jobs: list[Job], opts: Options, tools: FFmpegTools) -> None:
self._jobs, self._opts, self._tools = jobs, opts, tools
self._pos, self._taken, self._cancelled = 0, set(), False
for job in jobs:
job.status, job.progress, job.message, job.output = Status.QUEUED, 0.0, "", None
self.job_changed.emit(job)
self._next()
def cancel(self) -> None:
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self.log.emit("Stopping...")
self._proc.kill()
elif self._current is None:
self._finish_batch()
def kill_now(self) -> None:
"""Used on application exit: stop ffmpeg and remove the partial file."""
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self._proc.kill()
self._proc.waitForFinished(3000)
_remove_quietly(self._temp)
# -- internals ----------------------------------------------------------
def _emit_overall(self, current_fraction: float = 0.0) -> None:
if self._jobs:
self.progress.emit(min(1.0, (self._pos + current_fraction) / len(self._jobs)))
def _next(self) -> None:
while self._pos < len(self._jobs):
if self._cancelled:
break
job = self._jobs[self._pos]
if self._launch(job):
return
self._pos += 1
self._emit_overall()
self._finish_batch()
def _launch(self, job: Job) -> bool:
"""Start ffmpeg for ``job``. Returns False if the job finished immediately."""
opts = self._opts
out = plan_output_path(job.path, opts, self._taken)
if out is None:
job.status, job.message = Status.SKIPPED, "output already exists"
self.log.emit(f"Skipping {job.path.name}: output already exists")
self.job_changed.emit(job)
return False
try:
out.parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
job.status, job.message = Status.FAILED, f"cannot create output folder: {e}"
self.log.emit(f"ERROR {job.path.name}: {job.message}")
self.job_changed.emit(job)
return False
self._temp = temp_output_path(out)
built = build_command(self._tools.ffmpeg, job.info, opts, job.selected_audio, self._temp)
job.output = out
self._taken.add(os.path.normcase(os.path.abspath(out)))
self.log.emit("")
self.log.emit(f"=== [{self._pos + 1}/{len(self._jobs)}] {job.path}")
self.log.emit(f" -> {out}")
for note in built.notes:
self.log.emit(f" {note}")
self.log.emit(format_command(built.args))
self._current = job
self._stdout_buf = self._stderr_buf = b""
self._stderr_tail = []
job.status, job.progress, job.message = Status.RUNNING, 0.0, ""
self.job_changed.emit(job)
proc = QProcess(self)
proc.readyReadStandardOutput.connect(self._on_stdout)
proc.readyReadStandardError.connect(self._on_stderr)
proc.finished.connect(self._on_finished)
proc.errorOccurred.connect(self._on_error)
self._proc = proc
proc.start(built.args[0], built.args[1:])
return True
def _on_stdout(self) -> None:
self._stdout_buf += bytes(self._proc.readAllStandardOutput())
*lines, self._stdout_buf = self._stdout_buf.split(b"\n")
job = self._current
if job is None:
return
duration = job.info.duration if job.info else None
speed = None
updated = False
for raw in lines:
key, _, value = raw.decode(errors="replace").strip().partition("=")
if key == "out_time_us" and duration:
try:
job.progress = max(0.0, min(1.0, int(value) / 1e6 / duration))
updated = True
except ValueError:
pass
elif key == "speed" and value not in ("N/A", ""):
speed = value
if updated:
job.message = f"{job.progress * 100:.0f}%" + (f" @ {speed}" if speed else "")
self.job_changed.emit(job)
self._emit_overall(job.progress)
def _on_stderr(self) -> None:
self._stderr_buf += bytes(self._proc.readAllStandardError())
*lines, self._stderr_buf = self._stderr_buf.split(b"\n")
for raw in lines:
line = raw.decode(errors="replace").rstrip()
if line:
self._stderr_tail = (self._stderr_tail + [line])[-5:]
self.log.emit(f" ffmpeg: {line}")
def _on_error(self, error) -> None:
if error == QProcess.FailedToStart:
# finished() is not emitted in this case.
self._complete(ok=False, reason=f"could not start ffmpeg ({self._proc.errorString()})")
def _on_finished(self, exit_code: int, exit_status) -> None:
self._on_stderr()
ok = exit_status == QProcess.NormalExit and exit_code == 0
reason = "" if ok else (self._stderr_tail[-1] if self._stderr_tail else f"ffmpeg exited with code {exit_code}")
self._complete(ok, reason)
def _complete(self, ok: bool, reason: str) -> None:
job = self._current
if job is None:
return
self._current = None
if self._proc is not None:
self._proc.deleteLater()
self._proc = None
if self._cancelled:
job.status, job.message, job.progress = Status.CANCELLED, "", 0.0
_remove_quietly(self._temp)
self.log.emit(f"Cancelled {job.path.name}")
elif ok:
try:
os.replace(self._temp, job.output)
job.status, job.progress, job.message = Status.DONE, 1.0, ""
self.log.emit(f"Done: {job.output}")
except OSError as e:
job.status, job.message = Status.FAILED, f"could not rename output: {e}"
_remove_quietly(self._temp)
self.log.emit(f"ERROR {job.path.name}: {job.message}")
else:
job.status, job.message = Status.FAILED, reason
_remove_quietly(self._temp)
self.log.emit(f"FAILED {job.path.name}: {reason}")
self._temp = None
self.job_changed.emit(job)
self._pos += 1
self._emit_overall()
self._next()
def _finish_batch(self) -> None:
counts: dict[str, int] = {}
for job in self._jobs:
if job.status == Status.QUEUED:
job.status = Status.READY
self.job_changed.emit(job)
counts[job.status.value] = counts.get(job.status.value, 0) + 1
self._jobs = []
self.finished.emit(counts)