video_converter.progress のソースコード

"""FFmpeg の進捗を TCP 経由で受け取り、プログレスバーへ反映するモジュール。

FFmpeg の ``-progress tcp://127.0.0.1:<port>`` オプションを利用し、変換の
進捗を別プロセスから受信する。受信側は Observer パターンで実装されており、
:class:`FFmpegTCPSender` (Subject) が tqdm プログレスバーまたは GUI の
コールバック関数 (Observer) へ進捗を通知する。

FFmpeg の実行と進捗の受信を並行させるために ``gevent`` を用いるため、
:func:`run_pipeline_with_observer` を呼ぶ前に ``gevent.monkey.patch_all()``
が適用されている必要がある。CLI では :mod:`video_converter.__main__` の
先頭で適用している。
"""

import os
import socket
import warnings
from collections.abc import Callable

import ffmpeg
import gevent
import tqdm
from gevent import monkey

#: FFmpeg からの進捗用 TCP 接続を待つ既定の秒数。
#: FFmpeg が接続前に終了した場合に待ち続けないための上限。
DEFAULT_ACCEPT_TIMEOUT = 30.0

# tqdm の監視スレッドは gevent が patch した threading と噛み合わず、
# インタプリタ終了時に LoopExit を送出するため無効化する
tqdm.tqdm.monitor_interval = 0


[ドキュメント] def _ensure_monkey_patched() -> None: """``gevent`` の monkey patch が適用済みかを確認する。 未適用の場合は、この時点で ``socket`` を patch する。既に ``ssl`` などが import されている状態での patch は不完全になりうるため、あわせて警告する。 """ if monkey.is_module_patched("socket"): return warnings.warn( "gevent の monkey patch が未適用です。" " video_converter.__main__ を経由しない場合は、他のモジュールを" " import する前に gevent.monkey.patch_all() を呼んでください", RuntimeWarning, stacklevel=3, ) monkey.patch_all()
# Model (Observer pattern)
[ドキュメント] class FFmpegTCPSender: def __init__( self, pbar: tqdm.tqdm | Callable[[float], None], total: float, timeout: float = DEFAULT_ACCEPT_TIMEOUT, ): """ FFmpeg の進捗を TCP で受信し、プログレスバーへ通知する Subject。 :param pbar: 進捗の通知先 (Observer)。``n`` 属性を持つ tqdm プログレス バー、または経過秒数を 1 引数で受け取るコールバック関数のいずれか。 どちらであるかは :meth:`_notify_pbar` が実行時に判別する。 :param total: 動画の合計時間 (秒)。 :param timeout: FFmpeg からの接続を待つ秒数。FFmpeg が接続前に 終了した場合に待ち続けないための上限。 """ # Observer pattern self.pbar = pbar self.total = total self.time_pre = 0 self.timeout = timeout # bind や listen が失敗した場合でも close() が動くよう、 # ソケットを作る前に属性を用意しておく self.connection: socket.socket | None = None self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: # ポート番号 0 を指定して OS に空きポートを割り当てさせる。 # 事前に空きを探して後から bind すると、その間に別プロセスへ # 取られる余地がある self.sock.bind(("127.0.0.1", 0)) self.sock.listen(1) except OSError: self.sock.close() raise @property def port(self) -> int: """待ち受けているポート番号。 :return: OS が割り当てたポート番号。 """ return self.sock.getsockname()[1]
[ドキュメント] def close(self) -> None: """待ち受けソケットと接続済みソケットを閉じる。""" if self.connection is not None: self.connection.close() self.connection = None self.sock.close()
def __del__(self) -> None: """デストラクタ。ソケットを閉じる。 ``__init__`` が途中で失敗した場合は属性が揃っていないため、 存在を確認してから閉じる。 """ if getattr(self, "sock", None) is not None: self.close()
[ドキュメント] def _connect(self) -> socket.socket: """ FFmpeg からの TCP 接続を受け付ける。 :return: 接続済みの socket.socket オブジェクト。 :raises TimeoutError: ``timeout`` 秒以内に FFmpeg が接続してこない場合。 FFmpeg が起動直後に終了したときに待ち続けないためのガード。 """ self.sock.settimeout(self.timeout) try: self.connection, _ = self.sock.accept() except TimeoutError as exc: raise TimeoutError( f"FFmpeg が {self.timeout} 秒以内に " f"127.0.0.1:{self.port} へ接続しませんでした" ) from exc return self.connection
[ドキュメント] def _notify_pbar(self, key: str, value: str) -> None: """ プログレスバーを更新する。 :param key: FFmpeg から送信されたキー。 :param value: FFmpeg から送信された値。 """ if key == "out_time_ms": if value == "N/A": return time = round(float(value) / 1000000.0, 2) elif key == "progress" and value == "end": time = self.total else: return update_value = time - self.time_pre # tqdmの場合はn属性を使用、それ以外の場合(GUI用のコールバック関数)は直接更新 if hasattr(self.pbar, "n"): if 0 <= update_value <= self.total - self.pbar.n: self.pbar.update(update_value) self.time_pre = time else: # GUI用のコールバック関数の場合 if 0 <= update_value <= self.total: self.pbar(time) # コールバック関数を呼び出し self.time_pre = time
[ドキュメント] def tcp_handler(self) -> None: """ TCP データの受信および処理を行う。 :raises TimeoutError: FFmpeg が制限時間内に接続してこない場合。 """ try: connection = self._connect() data = b"" while True: more_data = connection.recv(4096) if not more_data: break data += more_data lines = data.split(b"\n") for line in lines[:-1]: key, _, value = line.decode().partition("=") self._notify_pbar(key, value) data = lines[-1] finally: self.close()
[ドキュメント] def probe_duration(path: str | os.PathLike) -> float: """動画の合計再生時間を取得する。 :param path: 動画のファイルパス。 :return: 合計再生時間 (秒)。 """ return float(ffmpeg.probe(path)["format"]["duration"])
[ドキュメント] def run_pipeline_with_observer( pipeline: ffmpeg.nodes.Node, observer: tqdm.tqdm | Callable[[float], None], total: float, timeout: float = DEFAULT_ACCEPT_TIMEOUT, ) -> tuple[bytes | None, bytes | None]: """FFmpeg を実行し、進捗を Observer へ通知する。 :param pipeline: FFmpeg の pipeline オブジェクト。 :param observer: 進捗の通知先。tqdm プログレスバーまたはコールバック関数。 :param total: 動画の合計時間 (秒)。 :param timeout: FFmpeg からの接続を待つ秒数。 :return: ``pipeline.run()`` の戻り値 (stdout, stderr) のタプル。 ``capture_stdout`` / ``capture_stderr`` を指定していないため、 いずれの要素も ``None`` になる。 :raises ffmpeg.Error: FFmpeg が異常終了した場合。 :raises TimeoutError: FFmpeg が制限時間内に進捗用の TCP 接続を 行わなかった場合。 """ _ensure_monkey_patched() # 先に bind してから FFmpeg へポート番号を伝えるため、 # 取得したポートを他プロセスに奪われる余地がない sender = FFmpegTCPSender(observer, total, timeout) # TODO:AF_INET の TCP 通信以外にしたい pipeline = pipeline.global_args("-progress", f"tcp://127.0.0.1:{sender.port}") greenlet_progress = gevent.spawn(sender.tcp_handler) greenlet_ffmpeg = gevent.spawn(pipeline.run) # FFmpeg が接続前に失敗した場合、進捗側の待ち受けを直ちに打ち切る greenlet_ffmpeg.link_exception(lambda _: greenlet_progress.kill(block=False)) gevent.joinall([greenlet_progress, greenlet_ffmpeg]) # gevent.joinall は greenlet の例外を送出しないため、明示的に確認する if not greenlet_ffmpeg.successful(): greenlet_ffmpeg.get() if not greenlet_progress.successful(): greenlet_progress.get() # Return the result of pipeline.run() return greenlet_ffmpeg.value
[ドキュメント] def run_with_tcp_pbar( path: str | os.PathLike, pipeline: ffmpeg.nodes.Node ) -> tuple[bytes | None, bytes | None]: """ FFmpeg の実行時に TCP 通信でプログレスバーを表示する。 :param path: 動画のファイルパス。合計時間の取得に使用する。 :param pipeline: FFmpeg の pipeline オブジェクト。 :return: ``pipeline.run()`` の戻り値 (stdout, stderr) のタプル。 ``capture_stdout`` / ``capture_stderr`` を指定していないため、 いずれの要素も ``None`` になる。 :raises ffmpeg.Error: FFmpeg が異常終了した場合。 :raises TimeoutError: FFmpeg が制限時間内に進捗用の TCP 接続を 行わなかった場合。 """ total = probe_duration(path) with tqdm.tqdm(total=total) as pbar: return run_pipeline_with_observer(pipeline, pbar, total)