Read ffmpeg progress from a file, not a pipe, to avoid deadlock
Driving -progress over pipe:1 made ffmpeg block on the progress write whenever the GUI stopped draining the pipe (e.g. the parent terminal or the app's event loop wedging), deadlocking every running encode. Write -progress to a temp file instead: a file write never blocks, so the encoder runs to completion regardless of GUI state. The executor calls optional start_/stop_progress_polling hooks; ffmpeg polls the file on a 500ms GLib timer and removes it when done. Verified an encode completes with no reader at all (previously a hard deadlock). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f574897c7
commit
480ec59ba4
2 changed files with 78 additions and 21 deletions
|
|
@ -109,6 +109,12 @@ class executor(GObject.GObject):
|
||||||
self.launch_process(self.command_var)
|
self.launch_process(self.command_var)
|
||||||
if self.use_pulse_mode != self.pulse_mode:
|
if self.use_pulse_mode != self.pulse_mode:
|
||||||
self.set_pulse_mode(self.use_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):
|
def remove_ansi(self, line):
|
||||||
|
|
||||||
|
|
@ -308,6 +314,11 @@ class executor(GObject.GObject):
|
||||||
retval = -1
|
retval = -1
|
||||||
|
|
||||||
self.set_pulse_mode(False)
|
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
|
# call, if it exists, the post-function
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from gi.repository import GLib
|
||||||
import devedeng.configuration_data
|
import devedeng.configuration_data
|
||||||
import devedeng.executor
|
import devedeng.executor
|
||||||
import devedeng.mux_dvd_menu
|
import devedeng.mux_dvd_menu
|
||||||
|
|
@ -182,11 +183,19 @@ class ffmpeg(devedeng.executor.executor):
|
||||||
self.command_var.append("-n")
|
self.command_var.append("-n")
|
||||||
self.command_var.append("10")
|
self.command_var.append("10")
|
||||||
self.command_var.append("ffmpeg")
|
self.command_var.append("ffmpeg")
|
||||||
# emit structured, steadily-paced progress on stdout (pipe:1) instead of
|
# Write structured, steadily-paced progress to a FILE (never a pipe):
|
||||||
# the carriage-return stats line, which barely updates over a pipe; this
|
# a file write can't block the encoder, so a wedged or crashed GUI can
|
||||||
# is what keeps the progress bar moving (see process_stdout)
|
# 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("-progress")
|
||||||
self.command_var.append("pipe:1")
|
self.command_var.append(self.progress_file)
|
||||||
self.command_var.append("-stats_period")
|
self.command_var.append("-stats_period")
|
||||||
self.command_var.append("0.5")
|
self.command_var.append("0.5")
|
||||||
self.command_var.append("-nostats")
|
self.command_var.append("-nostats")
|
||||||
|
|
@ -725,37 +734,74 @@ class ffmpeg(devedeng.executor.executor):
|
||||||
return (final_path)
|
return (final_path)
|
||||||
|
|
||||||
def process_stdout(self, data):
|
def process_stdout(self, data):
|
||||||
# ffmpeg -progress emits newline-delimited key=value pairs on stdout,
|
# progress is read from the -progress file (see start_progress_polling),
|
||||||
# e.g. out_time=00:01:23.456 / out_time_ms=83456000 / progress=continue.
|
# not stdout, so nothing to do here
|
||||||
# This updates steadily over a pipe, unlike the carriage-return stats.
|
|
||||||
if self.progress_bar is None:
|
|
||||||
return
|
return
|
||||||
for line in data:
|
|
||||||
|
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
|
||||||
|
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()
|
line = line.strip()
|
||||||
if line.startswith("out_time_ms=") or line.startswith("out_time_us="):
|
if line.startswith("out_time_ms=") or line.startswith("out_time_us="):
|
||||||
try:
|
try:
|
||||||
micros = float(line.split("=", 1)[1])
|
seconds = float(line.split("=", 1)[1]) / 1000000.0
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
continue
|
pass
|
||||||
seconds = micros / 1000000.0
|
|
||||||
self._set_progress_fraction(seconds)
|
|
||||||
elif line.startswith("out_time="):
|
elif line.startswith("out_time="):
|
||||||
value = line.split("=", 1)[1]
|
parts = line.split("=", 1)[1].split(":")
|
||||||
parts = value.split(":")
|
acc = 0.0
|
||||||
seconds = 0.0
|
|
||||||
ok = True
|
ok = True
|
||||||
for e in parts:
|
for e in parts:
|
||||||
try:
|
try:
|
||||||
seconds = seconds * 60.0 + float(e)
|
acc = acc * 60.0 + float(e)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ok = False
|
ok = False
|
||||||
break
|
break
|
||||||
if ok:
|
if ok:
|
||||||
self._set_progress_fraction(seconds)
|
seconds = acc
|
||||||
elif line == "progress=end":
|
elif line == "progress=end":
|
||||||
|
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_fraction(1.0)
|
||||||
self.progress_bar[1].set_text("100.0%")
|
self.progress_bar[1].set_text("100.0%")
|
||||||
return
|
return True
|
||||||
|
|
||||||
def _set_progress_fraction(self, seconds):
|
def _set_progress_fraction(self, seconds):
|
||||||
if (self.progress_bar is None) or (not self.final_length):
|
if (self.progress_bar is None) or (not self.final_length):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue