6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-10-04

はじめに

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

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

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

mermaid-diagram-2025-10-04-184632.png

このツールの特徴

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で公開していますので、興味がある方はぜひ試してみてください。

参考

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?