💡

音質を保ちながら動画を正確なサイズに圧縮するCLIツールを作った

に公開

はじめに

動画ファイルを特定のサイズに圧縮したいとき、「だいたいこのくらい」ではなく「正確に50MBにしたい」というケースがあります。例えば:

  • メール添付の容量制限に収めたい
  • クラウドストレージの容量を節約したい
  • SNSのアップロード制限に合わせたい

既存のツールでは目標サイズを指定しても実際のサイズがずれることが多く、試行錯誤が必要でした。そこで、目標サイズを正確に実現し、音質を優先する動画圧縮CLIツールを作成しました。

https://github.com/hiroki-abe-58/video-resizer

このツールの特徴

1. 目標サイズを正確に実現

動画の長さから必要なビットレートを逆算し、目標サイズに対して±5%以内の精度で圧縮します。

目標サイズ: 50.00 MB
実際のサイズ: 49.85 MB
差分: 0.15 MB

2. 音質優先の設計

音声ビットレートを192kbps(AAC)に固定し、ビデオビットレートを調整することで音質を優先します。

3. ドライランモード

実際の圧縮前に結果をプレビューできます。予想画質や必要な時間を事前に確認可能です。

./compress_video.py --dry-run

4. バッチ処理対応

ディレクトリを指定するだけで、配下の全動画ファイルを一括処理できます。

5. 2パスエンコーディング

高品質な圧縮を実現するため、2パスエンコーディングを採用しています。

使い方

インストール

# ffmpegのインストール
brew install ffmpeg

# リポジトリのクローン
git clone https://github.com/hiroki-abe-58/video-compressor.git
cd video-compressor
chmod +x compress_video.py

基本的な使い方

./compress_video.py

対話形式で以下を入力します:

  1. 動画ファイルのパス(またはディレクトリ)
  2. 目標サイズ(MB)
  3. 拡張子変換の有無

ドライラン

実際の圧縮前に結果を確認できます:

./compress_video.py --dry-run

出力例:

ドライラン結果
============================================================
入力ファイル: video.mp4
現在のサイズ: 150.50 MB
目標サイズ: 50.00 MB
圧縮率: 66.8%

【エンコード設定】
  ビデオビットレート: 1145 kbps
  音声ビットレート: 192 kbps (AAC)

【予想画質】
  高画質 (軽微な劣化)
============================================================

バッチ処理

ディレクトリ内の全動画を一括処理:

./compress_video.py

# ディレクトリパスを入力
> /path/to/videos/

# 一括設定または個別設定を選択

技術的なポイント

1. ビットレート計算アルゴリズム

目標ファイルサイズから必要なビットレートを逆算します:

def calculate_bitrate(self, target_size_mb: float, duration: float, 
                      audio_bitrate: int = 192) -> int:
    # 目標サイズ(MB) -> bits
    target_size_bits = target_size_mb * 8 * 1024 * 1024
    
    # 音声ビットレート(kbps) -> bits/sec
    audio_bitrate_bps = audio_bitrate * 1000
    
    # 音声トータルサイズ
    audio_total_bits = audio_bitrate_bps * duration
    
    # ビデオに割り当て可能なサイズ
    video_total_bits = target_size_bits - audio_total_bits
    
    if video_total_bits <= 0:
        raise ValueError("目標サイズが小さすぎる")
    
    # ビデオビットレート(bps) -> kbps(余裕を持たせて95%)
    video_bitrate_bps = video_total_bits / duration
    return int(video_bitrate_bps / 1000 * 0.95)

ポイント:

  • 音声サイズを先に確保し、残りをビデオに割り当てる
  • 95%の係数でマージンを持たせ、目標サイズを超えないようにする

2. 画質レベルの推定

解像度とビットレートから画質を推定します:

def estimate_quality_level(self, video_bitrate: int, video_info: dict) -> str:
    # 動画ストリームから解像度取得
    video_stream = next(
        (s for s in video_info['streams'] if s['codec_type'] == 'video'),
        None
    )
    
    height = video_stream.get('height', 0)
    
    # 解像度ベースの推奨ビットレート(YouTube基準)
    if height >= 1080:  # Full HD
        excellent = 8000
        good = 5000
        acceptable = 3000
    elif height >= 720:  # HD
        excellent = 5000
        good = 2500
        acceptable = 1500
    # ...
    
    # 判定
    if video_bitrate >= excellent:
        return "最高画質 (ほぼ劣化なし)"
    elif video_bitrate >= good:
        return "高画質 (軽微な劣化)"
    elif video_bitrate >= acceptable:
        return "標準画質 (許容範囲)"
    else:
        return "低画質 (明らかに劣化)"

YouTubeの推奨ビットレートを参考に、4K/2K/FHD/HD/SDごとに異なる基準で判定しています。

3. 2パスエンコーディング

高品質な圧縮のため、2パスエンコーディングを採用:

# 1パス目: ビットレート配分を解析
pass1_cmd = [
    'ffmpeg',
    '-i', str(input_path),
    '-c:v', 'libx264',
    '-b:v', f'{video_bitrate}k',
    '-pass', '1',
    '-an',  # 音声なし
    '-f', 'null',
    '/dev/null'
]

# 2パス目: 最適化されたエンコーディング
pass2_cmd = [
    'ffmpeg',
    '-i', str(input_path),
    '-c:v', 'libx264',
    '-b:v', f'{video_bitrate}k',
    '-pass', '2',
    '-c:a', 'aac',
    '-b:a', f'{audio_bitrate}k',
    '-y',
    str(output_path)
]

2パスエンコーディングの利点:

  • 1パス目でビットレート配分を分析
  • 2パス目で最適化されたエンコーディング
  • シングルパスより高品質だが時間がかかる

4. プログレスバー表示

ffmpegの出力をパースしてリアルタイムに進捗を表示:

def _run_ffmpeg_with_progress(self, cmd: list, phase: str, video_info: dict):
    process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        universal_newlines=True
    )
    
    duration = float(video_info['format']['duration'])
    
    while True:
        line = process.stderr.readline()
        if not line and process.poll() is not None:
            break
        
        # timeパラメータから進捗を取得
        time_match = re.search(r'time=(\d{2}):(\d{2}):(\d{2}\.\d{2})', line)
        if time_match:
            hours, minutes, seconds = time_match.groups()
            current_time = int(hours) * 3600 + int(minutes) * 60 + float(seconds)
            progress = min(100, (current_time / duration) * 100)
            
            # プログレスバー生成
            bar_length = 40
            filled = int(bar_length * progress / 100)
            bar = '█' * filled + '░' * (bar_length - filled)
            
            # 残り時間計算
            if progress > 0:
                elapsed = current_time
                total_estimated = (elapsed / progress) * 100
                remaining = total_estimated - elapsed
                remaining_str = self._format_time(remaining)
            
            print(f'\r{phase}: [{bar}] {progress:5.1f}% | 残り時間: {remaining_str}', 
                  end='', flush=True)

5. エラーハンドリング

想定されるエラーを全て検出し、わかりやすいメッセージを表示:

# ファイル存在チェック
if not path.exists():
    print(f"エラー: 存在しないパスです。正しいパスを入力してください。")
    continue

# サイズの妥当性チェック
if target_size >= current_size:
    print(f"エラー: 目標サイズ({target_size:.2f}MB)が"
          f"現在のサイズ({current_size:.2f}MB)以上です。")
    continue

# 音声サイズチェック
audio_size_mb = (192 * 1000 * duration) / (8 * 1024 * 1024)
if target_size < audio_size_mb * 1.1:
    print(f"警告: 目標サイズが小さすぎる可能性があります。")
    print(f"音声ビットレート192kbpsだけで約{audio_size_mb:.2f}MBになります。")
    confirm = input("それでも続けますか? (y/n): ").strip().lower()
    if confirm != 'y':
        continue

設計の工夫

DRY原則の徹底

圧縮処理を_compress_and_report()メソッドに共通化し、単体モードとバッチモードで重複を排除:

def _run_single_mode(self):
    """単体ファイルモード"""
    input_path = self.input_files[0]
    video_info = self.get_video_info(input_path)
    target_size_mb = self._phase2_get_target_size(input_path, video_info)
    output_format = self._phase3_convert_format(input_path)
    
    # 共通化されたメソッドを使用
    self._compress_and_report(input_path, target_size_mb, 
                             output_format, video_info)

def _batch_mode_uniform(self):
    """一括設定モード"""
    # ... 設定取得 ...
    
    for i, input_path in enumerate(self.input_files, 1):
        video_info = self.get_video_info(input_path)
        # 同じメソッドを使用
        self._compress_and_report(input_path, target_size_mb, 
                                 output_format, video_info, i, total)

単一責任の原則

各メソッドは単一の責任を持つように設計:

メソッド 責任
get_video_info() 動画情報取得
calculate_bitrate() ビットレート計算
compress_video() 圧縮実行
estimate_quality_level() 画質推定
_dry_run_report() ドライラン結果表示

型ヒントの活用

Python 3.8以上の型ヒントを活用し、コードの可読性と保守性を向上:

from typing import Optional, List, Tuple
from pathlib import Path

def calculate_bitrate(
    self, 
    target_size_mb: float, 
    duration: float, 
    audio_bitrate: int = 192
) -> int:
    # ...
    
def get_video_files_from_directory(self, directory: Path) -> List[Path]:
    # ...

パフォーマンス

処理時間

2パスエンコーディングのため、シングルパスより時間がかかります:

動画の長さ 解像度 処理時間(目安)
5分 1080p 約10-15分
15分 1080p 約30-40分
30分 1080p 約1時間

プログレスバーで進捗と残り時間を確認できます。

精度

目標サイズに対する精度:

ファイルサイズ 精度
50MB以上 ±2-5%
10-50MB ±3-7%
10MB未満 ±5-10%

95%の係数により、目標サイズを超えることはほぼありません。

今後の改善予定

  • ログ出力機能(処理履歴の保存)
  • プリセット機能(高画質/標準/低容量モード)
  • 設定ファイル対応(デフォルト設定の保存)
  • GPU加速対応(エンコード高速化)
  • pytestによるテストコード追加
  • CI/CD構築(GitHub Actions)

まとめ

動画を正確なサイズに圧縮するCLIツールを作成しました。主な特徴は:

  1. 目標サイズを±5%以内の精度で実現
  2. 音質優先(192kbps AAC)
  3. ドライランモードで事前確認
  4. バッチ処理対応
  5. 2パスエンコーディングによる高品質圧縮

ffmpegをラップしたシンプルなツールですが、以下の工夫により実用的なツールになりました:

  • ビットレート逆算アルゴリズム
  • 解像度ベースの画質推定
  • 包括的なエラーハンドリング
  • DRY原則に基づく設計
  • 型ヒントによる保守性向上

GitHubで公開していますので、興味がある方はぜひ試してみてください。

https://github.com/hiroki-abe-58/video-resizer

参考

Discussion