diff --git a/src/devedeng/executor.py b/src/devedeng/executor.py index 51286ea..82cf873 100644 --- a/src/devedeng/executor.py +++ b/src/devedeng/executor.py @@ -109,6 +109,12 @@ class executor(GObject.GObject): self.launch_process(self.command_var) if self.use_pulse_mode != self.pulse_mode: self.set_pulse_mode(self.use_pulse_mode) + # let subclasses start any timer-based progress polling (e.g. ffmpeg + # reads its -progress file on a GLib timer). No-op by default. + try: + self.start_progress_polling() + except AttributeError: + pass def remove_ansi(self, line): @@ -308,6 +314,11 @@ class executor(GObject.GObject): retval = -1 self.set_pulse_mode(False) + # stop any subclass progress polling timer + try: + self.stop_progress_polling() + except AttributeError: + pass # call, if it exists, the post-function try: diff --git a/src/devedeng/ffmpeg.py b/src/devedeng/ffmpeg.py index b82343c..b009e65 100644 --- a/src/devedeng/ffmpeg.py +++ b/src/devedeng/ffmpeg.py @@ -19,6 +19,7 @@ import subprocess import os import re +from gi.repository import GLib import devedeng.configuration_data import devedeng.executor import devedeng.mux_dvd_menu @@ -182,11 +183,19 @@ class ffmpeg(devedeng.executor.executor): self.command_var.append("-n") self.command_var.append("10") self.command_var.append("ffmpeg") - # emit structured, steadily-paced progress on stdout (pipe:1) instead of - # the carriage-return stats line, which barely updates over a pipe; this - # is what keeps the progress bar moving (see process_stdout) + # Write structured, steadily-paced progress to a FILE (never a pipe): + # a file write can't block the encoder, so a wedged or crashed GUI can + # never deadlock ffmpeg on backpressure. The app polls this file on a + # timer to update the progress bar (see start_progress_polling). + self.progress_file = output_file + ".progress" + try: + # start clean so we never read a previous run's position + if os.path.exists(self.progress_file): + os.remove(self.progress_file) + except OSError: + pass self.command_var.append("-progress") - self.command_var.append("pipe:1") + self.command_var.append(self.progress_file) self.command_var.append("-stats_period") self.command_var.append("0.5") self.command_var.append("-nostats") @@ -725,37 +734,74 @@ class ffmpeg(devedeng.executor.executor): return (final_path) def process_stdout(self, data): - # ffmpeg -progress emits newline-delimited key=value pairs on stdout, - # e.g. out_time=00:01:23.456 / out_time_ms=83456000 / progress=continue. - # This updates steadily over a pipe, unlike the carriage-return stats. - if self.progress_bar is None: + # progress is read from the -progress file (see start_progress_polling), + # not stdout, so nothing to do here + return + + def start_progress_polling(self): + """ Poll ffmpeg's -progress file on a GLib timer to drive the progress + bar. Reading a file never blocks the encoder, so a wedged or crashed + GUI can't deadlock ffmpeg on pipe backpressure. """ + self._progress_timer = None + if getattr(self, "progress_file", None) is None: return - for line in data: + self._progress_timer = GLib.timeout_add(500, self._poll_progress_file) + + def stop_progress_polling(self): + timer = getattr(self, "_progress_timer", None) + if timer is not None: + GLib.source_remove(timer) + self._progress_timer = None + # tidy up the temp progress file + pf = getattr(self, "progress_file", None) + if pf is not None: + try: + os.remove(pf) + except OSError: + pass + + def _poll_progress_file(self): + # returning True keeps the GLib timer alive + if self.progress_bar is None: + return True + pf = getattr(self, "progress_file", None) + if (pf is None) or (not os.path.exists(pf)): + return True + try: + with open(pf, "r") as f: + content = f.read() + except OSError: + return True + # ffmpeg appends progress blocks; the last out_time/progress is current + seconds = None + ended = False + for line in content.splitlines(): line = line.strip() if line.startswith("out_time_ms=") or line.startswith("out_time_us="): try: - micros = float(line.split("=", 1)[1]) + seconds = float(line.split("=", 1)[1]) / 1000000.0 except (ValueError, IndexError): - continue - seconds = micros / 1000000.0 - self._set_progress_fraction(seconds) + pass elif line.startswith("out_time="): - value = line.split("=", 1)[1] - parts = value.split(":") - seconds = 0.0 + parts = line.split("=", 1)[1].split(":") + acc = 0.0 ok = True for e in parts: try: - seconds = seconds * 60.0 + float(e) + acc = acc * 60.0 + float(e) except ValueError: ok = False break if ok: - self._set_progress_fraction(seconds) + seconds = acc elif line == "progress=end": - self.progress_bar[1].set_fraction(1.0) - self.progress_bar[1].set_text("100.0%") - return + ended = True + if seconds is not None: + self._set_progress_fraction(seconds) + if ended: + self.progress_bar[1].set_fraction(1.0) + self.progress_bar[1].set_text("100.0%") + return True def _set_progress_fraction(self, seconds): if (self.progress_bar is None) or (not self.final_length):