"""Tkinter による GUI インターフェース。
ウィンドウの構築は Builder パターン (:class:`WindowBuilder`) で行い、
ウィジェットへの参照は :class:`MyWindow` が保持する。変換の進捗は
:class:`video_converter.progress.FFmpegTCPSender` から
:class:`TkPBarWriter` のコールバックへ通知される (Observer パターン)。
"""
import sys
import time
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinterdnd2 import DND_FILES, TkinterDnD
from video_converter import progress
from video_converter.compressor import compress
from video_converter.converter import to_mp4
from video_converter.extractor import audio_eliminate, audio_extract
#: ラジオボタンの選択値と変換関数の対応表
METHODS = {
"to_mp4": to_mp4,
"audio_extract": audio_extract,
"audio_eliminate": audio_eliminate,
"compress": compress,
}
[ドキュメント]
def _browse_file(string_var: tk.StringVar) -> None:
"""ファイル選択ダイアログを開き、選択されたパスを変数へ設定する。
:param string_var: 選択されたファイルパスの格納先。
"""
file_path = filedialog.askopenfilename()
string_var.set(file_path)
[ドキュメント]
def _drop_file(event: "tk.Event", string_var: tk.StringVar) -> None:
"""ドラッグ&ドロップされたファイルパスを変数へ設定する。
tkinterdnd2 は Windows 形式のパスを ``{C:/path/to/file.mp4}`` のように
波括弧で囲んで渡すため、それを取り除いてから格納する。
:param event: ``<<Drop>>`` イベント。``data`` にファイルパスを含む。
:param string_var: ドロップされたファイルパスの格納先。
"""
# ドロップされたファイルパスを取得
# Windows形式のパス({C:/path/to/file.mp4})から通常のパスに変換
file_path = event.data.strip("{}")
string_var.set(file_path)
print(f"ファイルがドロップされました: {file_path}")
[ドキュメント]
class StdoutRedirector:
"""標準出力を Tkinter のテキストウィジェットへ転送するクラス。
:param text_widget: 出力先のテキストウィジェット。
"""
def __init__(self, text_widget: tk.Text):
self.text_widget = text_widget
[ドキュメント]
def write(self, message: str) -> int:
"""メッセージをテキストウィジェットへ書き込む。
ウィジェットが既に破棄されている場合は何もしない。
:param message: 書き込む文字列。
:return: 書き込んだ文字数。
"""
try:
self.text_widget.insert(tk.END, message)
# テキストを最後にスクロール
self.text_widget.see(tk.END)
except tk.TclError:
# ウィンドウが破棄された後の書き込みは無視する
return 0
return len(message)
[ドキュメント]
def flush(self) -> None:
"""``sys.stdout`` との互換性のために用意した何もしないメソッド。"""
# flushメソッドを追加(sys.stdoutの互換性のため)
pass
[ドキュメント]
def isatty(self) -> bool:
"""端末ではないことを示す。
:return: 常に ``False``。
"""
return False
[ドキュメント]
def fileno(self) -> int:
"""ファイルディスクリプタを持たないことを示す。
:raises OSError: 常に送出する。``sys.stdout`` を期待する側が
ディスクリプタを要求した場合に、誤った値を返さないため。
"""
raise OSError("StdoutRedirector はファイルディスクリプタを持ちません")
[ドキュメント]
class MyWindow:
"""GUI のウィジェットおよび状態変数への参照を保持するコンテナ。
各属性は :class:`WindowBuilder` の ``create_*`` メソッドによって設定され、
生成前は ``None`` である。
"""
def __init__(self):
# ウィンドウ
self.root: tk.Tk | None = None
# ファイルパス
self.entry_var: tk.StringVar | None = None
# 変換メソッドの種類
self.selected_method: tk.StringVar | None = None
# プログレスバー
self.percent: tk.StringVar | None = None
self.pb: ttk.Progressbar | None = None
self.remain: tk.StringVar | None = None
[ドキュメント]
class WindowBuilder:
""":class:`MyWindow` を組み立てる Builder。
``create_*`` メソッドはウィジェットを生成して返すと同時に、状態変数を
:attr:`window` へ登録する。配置 (grid) は呼び出し側の責務とする。
"""
def __init__(self):
# ウィンドウ
self.window = MyWindow()
[ドキュメント]
def create_window(self, geometry: str) -> None:
"""ルートウィンドウを生成する。
ドラッグ&ドロップを利用するため、``tk.Tk`` ではなく
``TkinterDnD.Tk`` を使用する。
:param geometry: ``"510x420"`` 形式のウィンドウサイズ。
"""
# TkinterDnDを使用してドラッグアンドドロップをサポートするウィンドウを作成
root = TkinterDnD.Tk()
root.geometry(geometry)
root.title("VideoConverter")
self.window.root = root
[ドキュメント]
def create_path_entry(self, width: int) -> tk.Entry:
"""ファイルパス入力欄を生成する。
入力欄自体をドロップ対象として登録する。
:param width: 入力欄の幅 (文字数)。
:return: ファイルパス入力欄。
"""
self.window.entry_var = tk.StringVar()
entry = tk.Entry(
self.window.root, textvariable=self.window.entry_var, width=width
)
# エントリーウィジェットにドラッグアンドドロップを設定
entry.drop_target_register(DND_FILES)
entry.dnd_bind("<<Drop>>", lambda e: _drop_file(e, self.window.entry_var))
return entry
[ドキュメント]
def create_method_options(self) -> list[tk.Radiobutton]:
"""変換方式を選択するラジオボタン群を生成する。
選択値は :data:`METHODS` のキー (``to_mp4``、``audio_extract``、
``audio_eliminate``、``compress``) に対応する。
:return: ラジオボタンのリスト。
"""
texts = ["MP4 へ変換", "MP3 へ変換", "音声除去", "動画圧縮"]
methods = ["to_mp4", "audio_extract", "audio_eliminate", "compress"]
# 選択された値を格納する変数を作成
self.window.selected_method = tk.StringVar()
self.window.selected_method.set("to_mp4")
# ラジオボタンを作成
radio_buttons = [
tk.Radiobutton(
self.window.root,
variable=self.window.selected_method,
text=text,
value=method,
)
for text, method in zip(texts, methods, strict=True)
]
return radio_buttons
[ドキュメント]
def create_pbar_items(
self, pbar_length: int
) -> tuple[tk.Label, ttk.Progressbar, tk.Label]:
"""進捗率ラベル・プログレスバー・残り時間ラベルを生成する。
プログレスバーは進捗を 0.0〜1.0 の割合で受け取る。
:param pbar_length: プログレスバーの長さ (ピクセル)。
:return: (進捗率ラベル, プログレスバー, 残り時間ラベル) のタプル。
"""
self.window.percent = tk.StringVar()
self.window.percent.set(" 0%")
percent = tk.Label(self.window.root, textvariable=self.window.percent)
self.window.pb = ttk.Progressbar(
self.window.root, length=pbar_length, mode="determinate", maximum=1
)
self.window.remain = tk.StringVar()
self.window.remain.set("[00:00<00:00] 0.0s/it")
remain = tk.Label(self.window.root, textvariable=self.window.remain)
return percent, self.window.pb, remain
[ドキュメント]
def create_console(self, height: int, width: int) -> tk.Text:
"""標準出力を表示するコンソールを生成する。
副作用として ``sys.stdout`` を :class:`StdoutRedirector` へ差し替え、
ルートウィンドウが破棄された時点で元の ``sys.stdout`` へ戻す
ハンドラを登録する。
:param height: コンソールの高さ (行数)。
:param width: コンソールの幅 (文字数)。
:return: コンソールとして使うテキストウィジェット。
"""
console = tk.Text(self.window.root, wrap=tk.WORD, height=height, width=width)
original_stdout = sys.stdout
sys.stdout = StdoutRedirector(console)
root = self.window.root
def restore_stdout(event: "tk.Event") -> None:
"""ルートウィンドウの破棄時に ``sys.stdout`` を元へ戻す。
:param event: ``<Destroy>`` イベント。子ウィジェットの破棄でも
発火するため、ルート自身かどうかを確認する。
"""
if event.widget is root:
sys.stdout = original_stdout
# WM_DELETE_WINDOW は「閉じるボタン」経由でしか発火しないため、
# destroy() を直接呼ばれた場合にも対応できる <Destroy> を使う
root.bind("<Destroy>", restore_stdout, add="+")
return console
[ドキュメント]
class TkPBarWriter:
"""進捗の通知を受けて GUI のプログレスバーを更新するクラス (Observer)。
:param total: 動画の合計時間 (秒)。生成時点では不明なため既定は ``None``
で、変換開始時に :func:`convert_and_send` が :meth:`reset` を通じて
設定する。
"""
def __init__(self, total: float | None = None):
self.total = total
self.start_time = time.time()
[ドキュメント]
def reset(self, total: float) -> None:
"""変換の開始に合わせて合計時間と計測開始時刻を初期化する。
:param total: 動画の合計時間 (秒)。
"""
self.total = total
self.start_time = time.time()
[ドキュメント]
def callback(self, step: float, window: MyWindow) -> None:
"""進捗をウィジェットへ反映する。
:param step: 変換済みの再生時間 (秒)。
:param window: 更新対象のウィジェットを保持するウィンドウ。
"""
window.pb.configure(value=step / self.total)
window.pb.update()
dt = time.time() - self.start_time
mean_speed = dt / step if step > 0 else 0
remain_time = mean_speed * (self.total - step) if step > 0 else 0
dt = "{:02d}:{:02d}".format(*divmod(int(dt), 60))
remain_time = "{:02d}:{:02d}".format(*divmod(int(remain_time), 60))
# StringVarを使用して値を更新
window.percent.set(f"{int(100 * step / self.total):02d}%")
window.remain.set(f"[{dt}<{remain_time}] {mean_speed:.1f}s/it")
[ドキュメント]
def create_window() -> MyWindow:
"""全ウィジェットを生成・配置したウィンドウを構築する。
``mainloop()`` は呼び出さないため、テストから利用できる。
:return: 構築済みのウィンドウ。
"""
builder = WindowBuilder()
builder.create_window(geometry="510x420")
# ウィンドウ全体にもドラッグアンドドロップを設定
builder.window.root.drop_target_register(DND_FILES)
builder.window.root.dnd_bind(
"<<Drop>>", lambda e: _drop_file(e, builder.window.entry_var)
)
# ファイルパス選択
browse_button = builder.create_browse_button()
path_entry = builder.create_path_entry(width=55)
# ファイル変換
pbar_writer = TkPBarWriter()
convert_button = builder.create_convert_button(pbar_writer)
radio_buttons = builder.create_method_options()
# プログレスバー
percent, pb, remain = builder.create_pbar_items(pbar_length=200)
# コンソール
console = builder.create_console(height=15, width=65)
# レイアウト配置
browse_button.grid(row=0, column=0, padx=20, pady=20)
path_entry.grid(row=0, column=1, columnspan=4, padx=0, pady=20)
convert_button.grid(row=1, column=0, padx=20, pady=0)
for idx, radio_button in enumerate(radio_buttons):
radio_button.grid(row=1, column=idx + 1)
percent.grid(row=2, column=0, padx=20, pady=20)
pb.grid(row=2, column=1, columnspan=2, padx=0, pady=0)
remain.grid(row=2, column=3, columnspan=2, padx=20, pady=20)
console.grid(row=3, column=0, columnspan=5, padx=20, pady=20)
window = builder.window
return window
[ドキュメント]
def convert_and_send(window: MyWindow, pbar_writer: TkPBarWriter) -> None:
"""GUI の入力内容にもとづき変換を実行する。
FFmpeg の実行と進捗受信を gevent の greenlet で並行に動かし、
両者の完了を待ち合わせる。
変換に失敗した場合、例外はそのままでは Tk が ``sys.stderr`` へ出力して
しまい、コンソール領域 (``sys.stdout`` を転送) にも表示されない。
``--noconsole`` でビルドした実行ファイルでは何も見えなくなるため、
ここで捕捉してダイアログとコンソールの両方へ表示する。
:param window: 入力値と進捗ウィジェットを保持するウィンドウ。
:param pbar_writer: 進捗をウィジェットへ反映するライター。
"""
try:
_convert(window, pbar_writer)
except Exception as exc: # noqa: BLE001
message = f"{type(exc).__name__}: {exc}"
print(f"変換に失敗しました: {message}")
messagebox.showerror("変換に失敗しました", message)
[ドキュメント]
def _convert(window: MyWindow, pbar_writer: TkPBarWriter) -> None:
"""GUI の入力内容にもとづき変換を実行する (例外はそのまま送出する)。
:param window: 入力値と進捗ウィジェットを保持するウィンドウ。
:param pbar_writer: 進捗をウィジェットへ反映するライター。
"""
# GUI から情報取得
path_str = window.entry_var.get()
method_str = window.selected_method.get()
print(f"Path: {path_str}")
print(f"Method: {method_str}")
# プログレスバー書き込み設定
total = progress.probe_duration(path_str)
pbar_writer.reset(total)
# FFmpeg 変換設定
pipeline = METHODS[method_str](path_str)
progress.run_pipeline_with_observer(
pipeline,
lambda step: pbar_writer.callback(step, window),
total,
)
[ドキュメント]
def open_window() -> None:
"""ウィンドウを構築し、イベントループを開始する。"""
window = create_window()
window.root.mainloop()
if __name__ == "__main__":
open_window()