設計をコードに落とし込む:video_analyzer.pyの全体像

前回の記事で、代表フレーム抽出・CLIP解析・動画特有の技術チェック(解像度/シーン数/ブラー/手ブレ/無音比率)という各パーツの設計は固まりました。 今回はこれらを1本のスクリプトvideo_analyzer.pyとして組み上げ、実際に動かします。

画像用のanalyzer.pyを土台に、処理の骨格自体はほぼそのまま流用できました。 変わるのは「1枚の画像を読み込む」部分が「動画から代表フレームを抽出する」部分に置き換わる点だけです。

def analyze_video(video_path: Path, cfg: dict) -> dict:
    duration, w, h, fps, codec = probe_video_meta(video_path, cfg["ffprobe_path"])
    frame_paths = extract_scene_frames(video_path, tmp_dir, cfg)
    if len(frame_paths) < cfg.get("min_frames", 3):
        frame_paths = extract_uniform_frames(video_path, tmp_dir, duration,
                                              count=cfg.get("fallback_frames", 6))

    frame_scores = [analyze_single_frame(p) for p in frame_paths]  # 画像版と共通の関数
    shake_score = calc_shake_score(video_path, cfg)
    silence_ratio = calc_silence_ratio(video_path, cfg["ffmpeg_path"])

    return {
        "duration": duration, "resolution": f"{w}x{h}", "fps": fps, "codec": codec,
        "clip_avg": mean(s["clip_score"] for s in frame_scores),
        "clip_min": min(s["clip_score"] for s in frame_scores),
        "shake_score": shake_score,
        "silence_ratio": silence_ratio,
        "scene_count": len(frame_paths),
    }

ポイントは、代表フレームそれぞれへの評価(analyze_single_frame)を画像版の解析関数からそのまま呼び出している点です。 設計段階で決めた「画像と全く同じCLIP解析を適用する」方針を、実装でもそのまま守っています。

フレーム抽出→CLIP解析→集約の実装

前回コードで示したextract_uniform_framesや手ブレ判定のオプティカルフロー計算は、 今回そのまま関数として切り出し、video_analyzer.pyの冒頭でimportする構成にしました。 以前は検証用に別スクリプトへベタ書きしていたものを、video_utils.pyという共通モジュールに移動させています。

# video_utils.py(設計段階のコードをモジュール化)
# video_analyzer.py
from video_utils import (
    extract_scene_frames,
    extract_uniform_frames,
    calc_shake_score,
    calc_silence_ratio
)

こうしてモジュール化しておくと、次回以降に予定している「Excelへの書き込み」「Streamlit UIからの呼び出し」でも同じ関数をそのまま使い回すことができます。 1つのファイルに実装をすべて詰め込まず、関数を切り出しておいたのは、後から見て正解でした。

① ffprobeのパス設定ミス

実装が一通り終わり、テスト動画で最初の実行をしたところ、いきなり次のエラーで落ちました。

FileNotFoundError: [WinError 2] 指定されたファイルが見つかりません: 'ffprobe'

画像用ツールの開発機ではffmpeg一式にPATHが通っていたため気づかなかったのですが、動画解析用にセットアップした別フォルダにはPATH設定が入っていませんでした。 subprocess.run(["ffprobe", ...])のようにコマンド名だけで呼び出していたのが原因です。

環境変数PATHに依存するのをやめ、設定ファイル側で明示的にffmpeg一式のディレクトリを指定する方式に変更しました。

"analyze_video": {
  "ffmpeg_dir": "E:\\tools\\ffmpeg\\bin",
  ...
}

# コード側
FFMPEG_PATH = str(Path(cfg["ffmpeg_dir"]) / "ffmpeg.exe")
FFPROBE_PATH = str(Path(cfg["ffmpeg_dir"]) / "ffprobe.exe")

これで実行環境のPATH設定に左右されず、設定ファイルさえ合わせれば別のPCでもそのまま動く状態になりました。

② subprocess出力の文字コードエラー

ffprobeのパスを直してテストを再開したところ、今度は一部の動画ファイルでだけ次のエラーが発生しました。

UnicodeDecodeError: 'cp932' codec can't decode byte 0x87 in position 12: illegal multibyte sequence

subprocess.run(cmd, capture_output=True, text=True)という書き方をしていたのですが、 text=Trueを指定すると出力はOSのデフォルトエンコーディング(日本語Windowsではcp932)でデコードされます。 ffmpegのログに含まれる一部の記号がcp932で表現できず、そこで例外が発生していました。

ffmpeg・ffprobeの出力は、今回の環境ではUTF-8として扱うことで問題を解消できたため、デコード方式を明示する形に修正しました。

# Before
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)

# After
result = subprocess.run(cmd, capture_output=True, timeout=30)
stdout = result.stdout.decode("utf-8", errors="replace")
stderr = result.stderr.decode("utf-8", errors="replace")

errors="replace"を付けているのは、万一デコードできない文字が残っていても、ログ解析自体を止めないようにするためです。 数値の抽出などクリティカルな処理には影響しない箇所なので、多少文字が化けても実害はないと判断しました。

③ Ollamaサーバー未起動によるエラー

文字コード問題を直したあと、CLIP解析の手前で今度は次のエラーが出ました。

ConnectionError: HTTPConnectionPool(host='localhost', port=11434): Max retries exceeded

画像用ツールのbatファイルはOllamaサーバーを自動起動する処理を持っていましたが、video_analyzer.pyは単体でPythonから直接実行してテストしていたため、 Ollamaサーバー自体が起動していませんでした。単純な確認漏れだったのですが、エラーメッセージだけを見ると原因が分かりにくいと感じたため、起動確認とリトライを入れることにしました。

def wait_for_ollama(url: str, retries: int = 3, wait_sec: int = 3) -> bool:
    for i in range(retries):
        try:
            r = requests.get(f"{url}/api/tags", timeout=3)
            if r.status_code == 200:
                return True
        except requests.exceptions.RequestException:
            pass
        print(f"[INFO] Ollamaサーバーへの接続待機中... ({i+1}/{retries})")
        time.sleep(wait_sec)
    return False

if not wait_for_ollama(cfg["ollama_url"]):
    print("[ERROR] Ollamaサーバーに接続できません。ollama serve を起動してください。")
    sys.exit(1)

本質的な原因はサーバーが単に起動していなかっただけですが、「何が起きているか分かるメッセージを出す」ことを優先し、 無言でスタックトレースだけが出る状態は避けるようにしました。

まとめ

設計段階で決めた各パーツ(フレーム抽出・CLIP解析・技術チェック)は、想定通りほぼそのままコードに落とし込むことができました。 むしろ時間がかかったのは、パス設定・文字コード・外部サーバー起動確認といった、解析ロジック本体とは関係のない周辺部分です。 特に文字コード関連は動画ファイルによって発生したりしなかったりするため、原因の切り分けに時間がかかりました。

今回のポイントまとめ

  • 設計済みの関数は早い段階でモジュール化しておくと、後工程でそのまま再利用できる
  • 外部コマンドのパスは環境変数PATHに依存せず、設定ファイルで明示的に指定する
  • subprocessの出力はtext=Trueに任せず、エンコーディングを明示してデコードする
  • 外部サーバー(Ollama等)への依存箇所は、ヘルスチェックとリトライを入れて原因を特定しやすくする

次回は、この解析結果をExcelに書き込む際に、画像用の設定ファイル・Excelとどう共存させるかという設計判断について記録します。

FAQ

Q. ffprobeのパスはどこで設定していますか?
stockphoto.jsonのanalyze_videoセクションにffmpeg_dirというキーを追加し、 そこからffprobeの実行ファイルパスを組み立てるようにしています。 環境変数PATHへの依存をやめ、設定ファイル側で明示的に指定する方式にしました。
Q. subprocessの出力を読むときに文字コードエラーが出るのはなぜですか?
subprocess.runの戻り値をtext=Trueで受け取ると、OSのデフォルトエンコーディング(日本語Windowsではcp932)でデコードされます。 ffmpegのログメッセージにcp932で表現できない文字が含まれると、そこでUnicodeDecodeErrorになります。result.stdout と result.stderr をUTF-8として明示的にデコードし、errors="replace"を指定することで解決しました。
Q. Ollamaサーバーが起動していない場合、video_analyzer.pyはどう振る舞いますか?
CLIPモデルを読み込む前に軽いヘルスチェックリクエストを送り、応答がなければ数秒待って最大3回までリトライします。 それでも応答がなければ、原因の分かるメッセージを表示して処理を終了するようにしています。

前の記事
← 品質判定・管理Excelの設計
次の記事 1つのExcelを画像・動画で共有する設計 →