スライド動画向け動画再エンコードによるサイズ削減

1. サマリー

本スクリプトは,講義動画等のスライド系映像を対象とした FFmpeg ラッパーである。GUI ダイアログによるファイル選択,エンコード前の変換内容確認,および 2パス方式による H.264/AAC エンコードを一貫して実行する。映像ビットレートを 800 kbps,フレームレートを 5 fps に抑えることで,スライド動画に特化したファイルサイズの削減を実現する。再生互換性の確保を最優先とし,広範な環境での再生を想定した設定を採用している。

2. 前準備(必要ソフトウェアの入手)

Python 3.12 のインストール(Windows 上) [クリックして展開]

以下のいずれかの方法で Python 3.12 をインストールする。Python がインストール済みの場合、この手順は不要である。

方法1:winget によるインストール

管理者権限コマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。

winget install -e --id Python.Python.3.12 --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 AssociateFiles=1 InstallLauncherAllUsers=1"

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。

方法2:インストーラーによるインストール

  1. Python 公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンから Windows 用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」に必ずチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

FFmpeg のインストール

FFmpeg は動画・音声の変換を行うコマンドラインツールである。本スクリプトでは ffmpeg(エンコード実行)と ffprobe(メディア情報の解析)の 2 つのバイナリを使用する。以下のいずれかの方法でインストールする。

方法1:winget によるインストール(推奨)

FFmpeg がインストール済みの場合,この手順は不要である。管理者権限のコマンドプロンプトで以下を実行する。

winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements

インストール完了後,コマンドプロンプトを再起動する。以下のコマンドで動作を確認する。

ffmpeg -version

バージョン情報が表示されれば成功である。「'ffmpeg' は、内部コマンドまたは外部コマンド~として認識されていません。」と表示される場合は,PATH が正しく設定されていない可能性がある。この問題は Gyan.FFmpeg の winget パッケージで報告されている既知の不具合である。その場合は,winget のインストール先フォルダ内の bin フォルダ(ffmpeg.exe が格納されているフォルダ)のパスを,システム環境変数 PATH に手動で追加する。追加手順は,Windows キーまたはスタートメニューから「環境変数」と入力し,「システム環境変数の編集」を開く。「環境変数」ボタンを押し,「システム環境変数」の Path を選択して「編集」から該当パスを追加する。設定後,コマンドプロンプトを再起動して ffmpeg -version で動作を確認する。手動での PATH 追加を行わない場合は,方法2 の手動入手に切り替える。

方法2:ZIP ファイルからの手動入手

  1. 以下の GitHub ページにアクセスする
  2. 「ffmpeg-master-latest-win64-gpl.zip」をダウンロードする。ページ内に多数のファイルが並んでいるが,ファイル名に win64-gpl を含む .zip ファイルを選択する
  3. ダウンロードした ZIP ファイルを展開する。展開後のフォルダ内に bin フォルダがあり,その中に ffmpeg.exe,ffprobe.exe,ffplay.exe の 3 つのファイルが含まれている。このうち ffmpeg.exe と ffprobe.exe を使用する

3. 本スクリプトの準備

本スクリプトの実行には,セクション 2 でインストールした Python 3.10 以降の環境と,FFmpeg バイナリ(ffmpeg.exe および ffprobe.exe)が必要である。標準ライブラリ(subprocess,json,shutil,sys,os,tkinter)のみを使用するため,追加パッケージのインストールは不要である。tkinter(GUI ライブラリ)は Windows 版 Python インストーラーに標準で含まれており,インストール時にオプションを変更していなければ追加の作業は不要である。

3.1 FFmpeg バイナリの配置

セクション 2.2 で winget を使用し,PATH が正しく設定されていることを確認済みであれば,追加の配置作業は不要である。

セクション 2.2 で ZIP ファイルから手動で入手した場合は,スクリプトと同じフォルダに ffmpeg.exe と ffprobe.exe を配置する。ZIP ファイルの bin フォルダ内から,この 2 つのファイルをスクリプトと同じフォルダにコピーする。

本スクリプトは,スクリプトと同じフォルダにあるバイナリを優先的に使用し,見つからない場合は PATH を検索する。

3.2 フォルダ構成の確認(手動配置の場合)

任意のフォルダ/
├── slide_encoder.py
├── ffmpeg.exe
└── ffprobe.exe

バイナリが見つからない場合,スクリプトはエラーダイアログを表示して終了する。

3.3 動作確認

配置が完了したら,コマンドプロンプトでスクリプトのフォルダに移動し,以下を実行する。

python slide_encoder.py

ファイル選択ダイアログが表示されれば,準備は完了である。

4. 概要・使い方・実行上の注意

概要

動作の流れは次のとおりである。

  1. GUI ダイアログで入力ファイルを選択する
  2. GUI ダイアログで出力ファイルのパスと形式を指定する
  3. ffprobe が入力ファイルを解析し,変換内容をターミナルおよびダイアログに表示する
  4. ユーザーが内容を確認して「OK」を押すと 2パスエンコードが開始される
  5. 完了後に完了通知ダイアログが表示される

セクション 5 に示す例では,1920×1080・30 fps・2500 kbps の lecture.mp4 を,800 kbps・5 fps の lecture_encoded.mp4 に変換している。映像ビットレートは 2300 kbps から 800 kbps へ,フレームレートは 30 fps から 5 fps へ削減されるため,スライドの静止時間が大半を占める動画で効果的にファイルサイズを削減できる。

使い方

python slide_encoder.py

スクリプトを実行するとダイアログが順に表示されるため,コマンドライン引数の指定は不要である。

実行上の注意

項目内容
入出力の同一指定同一パスを指定した場合はエラーで停止する
エンコード時間プリセット veryslow を使用するため,実時間の数倍の処理時間を要する
一時ファイルエンコード中に ffmpeg2pass-0.log 等が生成され,完了後に自動削除される
出力形式MP4・M4V・MOV の場合は faststart フラグが付与され,ストリーミング再生の開始が高速化される
フレームレート制限5 fps はスライド動画を前提とした設定であり,動きの多い映像には適さない
中断時の残留ファイルエンコードを途中で強制終了した場合,一時ファイルが残留することがある。手動での削除が必要である

5. 確認ダイアログの表示例

以下は,入力ファイル解析後に表示される確認ダイアログの内容例である。

━━━ 入力ファイル ━━━
  ファイル : lecture.mp4
  時間長  : 01:23:45
  全体   : 2500 kbps
  映像   : H264  1920x1080  30.00 fps  2300 kbps
  音声   : AAC  44100 Hz  2ch  192 kbps

━━━ 変換内容 ━━━
  映像コーデック : H.264 (libx264)
  映像ビットレート : 800 kbps
  フレームレート : 5 fps に変換
  プリセット   : veryslow(最高圧縮・低速)
  プロファイル  : Main / Level 3.1
  色空間     : yuv420p(最高互換)
  エンコード方式 : 2パス(品質安定)
  faststart    : 有効(再生開始を高速化)
  音声コーデック : AAC
  音声ビットレート : 192 kbps
  音声リサンプル : async=1(音ズレ補正)

━━━ 出力先 ━━━
  C:\Users\...\lecture_encoded.mp4

6. ソースコード

"""
スライド動画向け 2パスエンコード
- 再生互換性を最重視した設定
- tkinter によるファイル選択・保存ダイアログ
- エンコード前に変換内容を明示して確認

準備: ffmpeg.exe と ffprobe.exe をスクリプトと同じフォルダに配置
      (または PATH に通っていれば不要)
取得先: https://github.com/BtbN/FFmpeg-Builds/releases
"""

import subprocess
import json
import shutil
import sys
import os
import tkinter as tk
from tkinter import filedialog, messagebox

# === FFmpeg / FFprobe バイナリ検出 ===
# スクリプトと同じフォルダ → PATH の順に探す
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

def find_bin(name: str) -> str:
    local = os.path.join(SCRIPT_DIR, name + (".exe" if os.name == "nt" else ""))
    if os.path.isfile(local):
        return local
    found = shutil.which(name)
    if found:
        return found
    messagebox.showerror("エラー", f"{name} が見つかりません。\n"
                         f"スクリプトと同じフォルダか、PATHに配置してください。")
    sys.exit(1)

FFMPEG = find_bin("ffmpeg")
FFPROBE = find_bin("ffprobe")
print(f"FFmpeg : {FFMPEG}")
print(f"FFprobe: {FFPROBE}")

# === エンコード設定 ===
VIDEO_BITRATE = "800k"
VIDEO_BITRATE_DISPLAY = "800 kbps"
AUDIO_BITRATE = "192k"
AUDIO_BITRATE_DISPLAY = "192 kbps"
FPS = "5"
PASSLOGFILE = os.path.join(SCRIPT_DIR, "ffmpeg2pass")
NULL_DEV = "NUL" if os.name == "nt" else "/dev/null"

# === 対応フォーマット ===
INPUT_FILETYPES = [
    ("動画ファイル",
     "*.mp4 *.mkv *.avi *.mov *.wmv *.flv *.webm *.m4v *.mpg *.mpeg "
     "*.ts *.mts *.m2ts *.vob *.3gp *.3g2 *.ogv *.rm *.rmvb *.asf"),
    ("すべてのファイル", "*.*"),
]

OUTPUT_EXTS = {
    ".mp4": "MP4", ".mkv": "Matroska", ".avi": "AVI", ".mov": "QuickTime",
    ".ts": "MPEG-TS", ".flv": "FLV", ".m4v": "M4V", ".wmv": "WMV",
    ".webm": "WebM", ".mpg": "MPEG", ".3gp": "3GP",
}


# =========================================================================
#  ユーティリティ
# =========================================================================

def fmt_bitrate(bps: str | None) -> str:
    try:
        return f"{int(bps) // 1000} kbps"
    except (ValueError, TypeError):
        return "不明"


def fmt_duration(sec: str | None) -> str:
    try:
        t = int(float(sec))
        return f"{t // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}"
    except (ValueError, TypeError):
        return "不明"


def fmt_fps(r_frame_rate: str) -> str:
    try:
        num, den = r_frame_rate.split("/")
        return f"{int(num) / int(den):.2f}"
    except (ValueError, ZeroDivisionError):
        return r_frame_rate or "不明"


def probe_file(path: str) -> dict:
    """ffprobe で入力ファイルのメタ情報を取得する"""
    r = subprocess.run(
        [FFPROBE, "-v", "quiet", "-print_format", "json",
         "-show_format", "-show_streams", path],
        capture_output=True, text=True, encoding="utf-8", errors="replace",
    )
    if r.returncode != 0:
        return {}
    try:
        return json.loads(r.stdout)
    except json.JSONDecodeError:
        return {}


def find_stream(probe: dict, codec_type: str) -> dict:
    """指定タイプの最初のストリームを返す"""
    return next((s for s in probe.get("streams", [])
                 if s.get("codec_type") == codec_type), {})


# =========================================================================
#  変換内容の確認テキスト
# =========================================================================

def build_summary(input_path: str, output_path: str, probe: dict) -> str:
    v = find_stream(probe, "video")
    a = find_stream(probe, "audio")
    f = probe.get("format", {})
    out_ext = os.path.splitext(output_path)[1].lower()

    v_info = (f"{v.get('codec_name', '不明').upper()}  "
              f"{v.get('width', '?')}x{v.get('height', '?')}  "
              f"{fmt_fps(v.get('r_frame_rate', ''))} fps  "
              f"{fmt_bitrate(v.get('bit_rate'))}") if v else "なし"

    a_info = (f"{a.get('codec_name', '不明').upper()}  "
              f"{a.get('sample_rate', '?')} Hz  {a.get('channels', '?')}ch  "
              f"{fmt_bitrate(a.get('bit_rate'))}") if a else "なし"

    faststart = "有効(再生開始を高速化)" if out_ext in (".mp4", ".m4v", ".mov") else "なし"

    return (
        f"━━━ 入力ファイル ━━━\n"
        f"  ファイル : {os.path.basename(input_path)}\n"
        f"  時間長  : {fmt_duration(f.get('duration'))}\n"
        f"  全体   : {fmt_bitrate(f.get('bit_rate'))}\n"
        f"  映像   : {v_info}\n"
        f"  音声   : {a_info}\n"
        f"\n"
        f"━━━ 変換内容 ━━━\n"
        f"  映像コーデック : H.264 (libx264)\n"
        f"  映像ビットレート : {VIDEO_BITRATE_DISPLAY}\n"
        f"  フレームレート : {FPS} fps に変換\n"
        f"  プリセット   : veryslow(最高圧縮・低速)\n"
        f"  プロファイル  : Main / Level 3.1\n"
        f"  色空間     : yuv420p(最高互換)\n"
        f"  エンコード方式 : 2パス(品質安定)\n"
        f"  faststart    : {faststart}\n"
        f"  音声コーデック : AAC\n"
        f"  音声ビットレート : {AUDIO_BITRATE_DISPLAY}\n"
        f"  音声リサンプル : async=1(音ズレ補正)\n"
        f"\n"
        f"━━━ 出力先 ━━━\n"
        f"  {output_path}"
    )


# =========================================================================
#  エンコード本体
# =========================================================================

def encode(input_path: str, output_path: str):
    """2パスエンコードを実行する"""
    out_ext = os.path.splitext(output_path)[1].lower()

    video_opts = [
        "-c:v", "libx264", "-b:v", VIDEO_BITRATE, "-preset", "veryslow",
        "-vf", f"fps={FPS}", "-profile:v", "main", "-level", "3.1",
        "-pix_fmt", "yuv420p",
    ]
    faststart = ["-movflags", "+faststart"] if out_ext in (".mp4", ".m4v", ".mov") else []

    for pass_n, extra in [("1", ["-an", "-f", "null", NULL_DEV]),
                          ("2", ["-c:a", "aac", "-b:a", AUDIO_BITRATE,
                                 "-af", "aresample=async=1", *faststart, output_path])]:
        print(f"=== パス{pass_n} 開始 ===")
        ret = subprocess.run(
            [FFMPEG, "-y", "-i", input_path, *video_opts,
             "-pass", pass_n, "-passlogfile", PASSLOGFILE, *extra],
            encoding="utf-8", errors="replace",
        )
        if ret.returncode != 0:
            messagebox.showerror("エラー", f"パス{pass_n} でエラーが発生しました。")
            sys.exit(1)

    for f in [f"{PASSLOGFILE}-0.log", f"{PASSLOGFILE}-0.log.mbtree"]:
        try:
            os.remove(f)
        except FileNotFoundError:
            pass


# =========================================================================
#  メイン
# =========================================================================

def main():
    root = tk.Tk()
    root.withdraw()

    # --- 入力ファイル選択 ---
    input_path = filedialog.askopenfilename(title="入力ファイルを選択", filetypes=INPUT_FILETYPES)
    if not input_path:
        sys.exit(0)

    # --- 出力ファイル選択 ---
    in_dir = os.path.dirname(input_path)
    in_stem, in_ext = os.path.splitext(os.path.basename(input_path))
    out_types = ([(f"{OUTPUT_EXTS[in_ext.lower()]} ファイル", f"*{in_ext}")] if in_ext.lower() in OUTPUT_EXTS else [])
    out_types += [(f"{v} ファイル", f"*{k}") for k, v in OUTPUT_EXTS.items() if k != in_ext.lower()]
    out_types.append(("すべてのファイル", "*.*"))

    output_path = filedialog.asksaveasfilename(
        title="出力ファイルの保存先を選択", initialdir=in_dir,
        initialfile=f"{in_stem}_encoded{in_ext}", defaultextension=in_ext, filetypes=out_types,
    )
    if not output_path:
        sys.exit(0)

    # --- 入出力同一チェック ---
    if os.path.abspath(input_path) == os.path.abspath(output_path):
        messagebox.showerror("エラー", "入力ファイルと出力ファイルが同じです。")
        sys.exit(1)

    # --- 入力ファイル解析・確認 ---
    probe = probe_file(input_path)
    if not probe:
        messagebox.showerror("エラー", "入力ファイルの解析に失敗しました。")
        sys.exit(1)

    summary = build_summary(input_path, output_path, probe)
    print(summary)
    if not messagebox.askokcancel("変換内容の確認", summary):
        sys.exit(0)

    # --- エンコード実行 ---
    encode(input_path, output_path)
    messagebox.showinfo("完了", f"エンコードが完了しました。\n{output_path}")


if __name__ == "__main__":
    main()