動画情報チェッカー

この記事にはアフィリエイトリンクが含まれており、この記事経由で契約すると私に紹介料が入ります。詳しくは プライバシーポリシーをご覧ください。

現在でも、動画情報を取得する解析プログラムは真空波動拳LiteMediaInfoなど存在しているのですが、情報がまとまって表示されたり、最近流行りのWebMだと取得できないこともあったりするので、自分で分かりやすいやつ欲しいなぁと思いPythonで作ってみました。

現状の簡易版で良ければと思い、共有しておきます。また余裕があれば諸々変更したいと思います。
ffmpegがインストールされ、環境変数にPATHが通ってること、Pythonがインストールされてる事が前提になりますが、下記のコードで使用できます。

【使い方】
① 下記コードを好きなファイル名で好きな場所へ配置。(video_info_viewer.pyなど)
② ショートカットを作成し、WindowsのSendto(送る)へ移動させる。
③ 調べたい動画を選択し右クリック→送る→video_info_viewerを選択するだけです。

import tkinter as tk
from tkinter import ttk, messagebox
import subprocess
import json
import sys
import os
from decimal import Decimal, ROUND_HALF_UP

# tkinterdnd2のインポートを試みる
DND_AVAILABLE = False
try:
    import tkinterdnd2 as tkdnd
    DND_AVAILABLE = True
except ImportError:
    pass

# --- 表示名のマッピング ---
CODEC_MAP = {
    'hevc': 'hevc (H.265)', 'h264': 'h264 (AVC)', 'avc': 'avc (H.246)',
    'ac3': 'ac3 (DolbyDigital)', 'eac3': 'eac3 (DolbyDigital+)',
    'dts': 'dts (DTS)',
    'matroska': 'matroska (MKV)',
}

# --- UIの配色とサイズ定義 ---
COLORS = { "background": "#3C3C3C", "frame_bg": "#2D2D2D", "text": "#DCDCDC", "header": "#FFFFFF", "separator": "#5A5A5A" }
WINDOW_WIDTH = 410
WINDOW_HEIGHT = 298

class VideoInfoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("動画情報チェッカー")
        self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
        self.root.minsize(WINDOW_WIDTH, WINDOW_HEIGHT)
        self.root.configure(bg=COLORS["background"])

        style = ttk.Style()
        style.theme_use('clam')
        style.configure('.', background=COLORS["frame_bg"], foreground=COLORS["text"], font=('Yu Gothic UI', 9))
        style.configure('TFrame', background=COLORS["frame_bg"])
        style.configure('TLabel', background=COLORS["frame_bg"], foreground=COLORS["text"])
        style.configure('Header.TLabel', foreground=COLORS["header"], font=('Yu Gothic UI', 10, 'bold'))
        style.configure('TSeparator', background=COLORS["separator"])
        style.configure('Vertical.TSeparator', background=COLORS["separator"])

        main_frame = ttk.Frame(root, style='TFrame'); main_frame.pack(fill="both", expand=True, padx=1, pady=1)
        container = ttk.Frame(main_frame, padding=(12, 10)); container.pack(fill="both", expand=True)
        self.info_labels = {}

        top_frame = ttk.Frame(container); top_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)); top_frame.columnconfigure(1, weight=1)
        
        drop_text = "--- (ファイルをドラッグ&ドロップ)" if DND_AVAILABLE else "--- (ファイルは引数で指定)"
        ttk.Label(top_frame, text="ファイル名:").grid(row=0, column=0, sticky="nw", padx=(0, 5))
        self.filename_label = ttk.Label(top_frame, text=drop_text, anchor="w"); self.filename_label.grid(row=0, column=1, sticky="ew")
        ttk.Label(top_frame, text="コンテナ名:").grid(row=1, column=0, sticky="w", padx=(0, 5))
        self.info_labels['コンテナ名'] = ttk.Label(top_frame, text="---", anchor="w"); self.info_labels['コンテナ名'].grid(row=1, column=1, sticky="ew")

        ttk.Separator(container, orient='horizontal').grid(row=1, column=0, sticky='ew', pady=10)

        bottom_frame = ttk.Frame(container); bottom_frame.grid(row=2, column=0, sticky="ew"); bottom_frame.columnconfigure(0, weight=1, uniform="group1"); bottom_frame.columnconfigure(2, weight=1, uniform="group1")
        video_frame = ttk.Frame(bottom_frame); video_frame.grid(row=0, column=0, sticky="nsew"); video_frame.columnconfigure(1, weight=1)
        ttk.Label(video_frame, text="映像情報", style='Header.TLabel').grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 2))
        self.create_info_row(video_frame, 1, "映像コーデック:", "映像コーデック"); self.create_info_row(video_frame, 2, "映像ビットレート:", "映像ビットレート"); self.create_info_row(video_frame, 3, "フレームレート:", "フレームレート"); self.create_info_row(video_frame, 4, "プロファイル:", "プロファイル"); self.create_info_row(video_frame, 5, "解像度:", "解像度"); self.create_info_row(video_frame, 6, "再生時間:", "再生時間_v")
        
        ttk.Separator(bottom_frame, orient='vertical').grid(row=0, column=1, sticky='ns', padx=10)
        
        audio_frame = ttk.Frame(bottom_frame); audio_frame.grid(row=0, column=2, sticky="nsew"); audio_frame.columnconfigure(1, weight=1)
        ttk.Label(audio_frame, text="音声情報", style='Header.TLabel').grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 2))
        self.create_info_row(audio_frame, 1, "音声コーデック:", "音声コーデック"); self.create_info_row(audio_frame, 2, "音声ビットレート:", "音声ビットレート"); self.create_info_row(audio_frame, 3, "サンプリング周波数:", "サンプリング周波数"); self.create_info_row(audio_frame, 4, "チャンネルレイアウト:", "チャンネルレイアウト"); self.create_info_row(audio_frame, 5, "言語:", "言語"); self.create_info_row(audio_frame, 6, "再生時間:", "再生時間_a")

        self.root.bind('<Configure>', self.update_wraplength)
        
        if DND_AVAILABLE:
            self.root.drop_target_register(tkdnd.DND_FILES)
            self.root.dnd_bind('<<Drop>>', self.on_drop)

        if len(sys.argv) > 1:
            self.root.after(100, lambda: self.process_file(sys.argv[1]))

    def create_info_row(self, parent, row, label_text, key):
        ttk.Label(parent, text=label_text).grid(row=row, column=0, sticky="w", padx=(0, 5))
        value_label = ttk.Label(parent, text="---", anchor="w"); value_label.grid(row=row, column=1, sticky="ew"); self.info_labels[key] = value_label

    def update_wraplength(self, event=None):
        self.filename_label.configure(wraplength=self.root.winfo_width() - 100)
    
    def on_drop(self, event):
        self.process_file(event.data.strip('{}'))
    
    def process_file(self, filepath):
        if not os.path.isfile(filepath):
            messagebox.showerror("エラー", f"ファイルが見つかりません:\n{filepath}")
            return
        self.update_info_labels()
        try:
            video_info = self.get_video_info(filepath)
            self.update_info_labels(video_info)
        except Exception as e:
            messagebox.showerror("エラー", f"情報の取得に失敗しました: {e}")

    def get_total_seconds(self, duration_val):
        if duration_val is None: return None
        try:
            return Decimal(duration_val)
        except Exception:
            if isinstance(duration_val, str) and ':' in duration_val:
                try:
                    parts = duration_val.split(':')
                    sec_part = parts[-1]
                    s = Decimal(sec_part)
                    m = Decimal(parts[1]) if len(parts) > 1 else Decimal(0)
                    h = Decimal(parts[0]) if len(parts) > 2 else Decimal(0)
                    return h * 3600 + m * 60 + s
                except Exception:
                    return None
            return None

    def format_duration(self, total_seconds):
        if total_seconds is None: return 'N/A'
        
        total_hundredths = (total_seconds * 100).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
        h, r = divmod(int(total_hundredths), 3600 * 100)
        m, r = divmod(r, 60 * 100)
        s, cs = divmod(r, 100)
        return f"{h:02}:{m:02}:{s:02}.{cs:02d}"

    def get_video_info(self, filepath):
        command = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filepath]
        startupinfo = None
        if sys.platform == "win32":
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        result = subprocess.run(command, capture_output=True, text=True, encoding='utf-8', startupinfo=startupinfo)
        if result.returncode != 0: raise RuntimeError(f"ffprobeエラー:\n{result.stderr}")

        data = json.loads(result.stdout)
        info = {'ファイル名': os.path.basename(filepath)}
        
        format_info = data.get('format', {})
        container = format_info.get('format_name', 'N/A').split(',')[0].strip()
        info['コンテナ名'] = CODEC_MAP.get(container, container)
        
        video_stream = next((s for s in data['streams'] if s.get('codec_type') == 'video' and s.get('disposition', {}).get('attached_pic', 0) == 0),
                            next((s for s in data['streams'] if s.get('codec_type') == 'video'), None))
        audio_stream = next((s for s in data['streams'] if s.get('codec_type') == 'audio'), None)
        
        if video_stream:
            codec = video_stream.get('codec_name', 'N/A'); info['映像コーデック'] = CODEC_MAP.get(codec, codec)
            info['プロファイル'] = video_stream.get('profile', 'N/A')
            info['解像度'] = f"{video_stream.get('width', '?')}x{video_stream.get('height', '?')}"
            
            fr_str = video_stream.get('avg_frame_rate', '0/0')
            frame_rate = None
            if fr_str != '0/0':
                try:
                    num, den = map(int, fr_str.split('/'))
                    if den > 0:
                        frame_rate = Decimal(num) / Decimal(den)
                        info['フレームレート'] = f"{frame_rate:.3f} fps"
                    else: info['フレームレート'] = 'N/A'
                except ValueError: info['フレームレート'] = 'N/A'
            else: info['フレームレート'] = 'N/A'

            v_bitrate = video_stream.get('bit_rate') or format_info.get('bit_rate')
            info['映像ビットレート'] = f"{int(Decimal(v_bitrate)) // 1000} kbps" if v_bitrate else 'N/A'
            
            # --- 【重要】あなたのオリジナルの、正しく動作していた映像再生時間ロジック ---
            total_v_seconds = None
            nb_frames_str = video_stream.get('nb_frames')
            if nb_frames_str and nb_frames_str.isdigit() and frame_rate and frame_rate > 0:
                total_v_seconds = Decimal(nb_frames_str) / frame_rate
            else: 
                raw_v_duration = video_stream.get('tags', {}).get('DURATION') or video_stream.get('duration') or format_info.get('duration')
                total_v_seconds = self.get_total_seconds(raw_v_duration)
            info['再生時間_v'] = self.format_duration(total_v_seconds)
            # --- 映像ロジックここまで ---

        if audio_stream:
            codec = audio_stream.get('codec_name', 'N/A'); info['音声コーデック'] = CODEC_MAP.get(codec, codec)
            a_bitrate = audio_stream.get('bit_rate'); info['音声ビットレート'] = f"{int(Decimal(a_bitrate)) // 1000} kbps" if a_bitrate else 'N/A'
            sr_str = audio_stream.get('sample_rate'); info['サンプリング周波数'] = f"{sr_str} Hz" if sr_str else 'N/A'
            info['チャンネルレイアウト'] = audio_stream.get('channel_layout', 'N/A')
            info['言語'] = audio_stream.get('tags', {}).get('language', 'N/A')

            # --- 【重要】最終的に合意した音声再生時間ロジック ---
            total_a_seconds = None
            audio_tags = audio_stream.get('tags', {})
            # 1. 音声ストリーム自身のtags.DURATIONを最優先
            if 'DURATION' in audio_tags:
                total_a_seconds = self.get_total_seconds(audio_tags['DURATION'])
            
            # 2. それがなければ、他の情報をフォールバックとして使用
            if total_a_seconds is None:
                raw_a_duration = audio_stream.get('duration')
                if raw_a_duration:
                    total_a_seconds = self.get_total_seconds(raw_a_duration)
                elif 'duration_ts' in audio_stream and sr_str and sr_str.isdigit():
                     total_a_seconds = Decimal(audio_stream['duration_ts']) / Decimal(sr_str)
                else:
                    total_a_seconds = self.get_total_seconds(format_info.get('duration'))
            info['再生時間_a'] = self.format_duration(total_a_seconds)
            # --- 音声ロジックここまで ---
            
        return info

    def update_info_labels(self, info=None):
        if info is None: info = {}
        self.filename_label.config(text=info.get('ファイル名', "---"))
        for key, label in self.info_labels.items():
            value = info.get(key, "---"); label.config(text=value)
        self.root.after(50, self.update_wraplength)

if __name__ == "__main__":
    try:
        startupinfo = None
        if sys.platform == "win32":
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        subprocess.run(['ffprobe', '-version'], check=True, capture_output=True, startupinfo=startupinfo)
    except (FileNotFoundError, subprocess.CalledProcessError):
        root_check = tk.Tk()
        root_check.withdraw()
        messagebox.showerror("起動エラー", "ffprobeコマンドが見つかりませんでした。\nFFmpegをインストールし、PATHを設定してください。")
        sys.exit(1)

    if DND_AVAILABLE:
        root = tkdnd.Tk()
    else:
        root = tk.Tk()
        
    app = VideoInfoApp(root)
    root.mainloop()

編集後記

過去、何人かのプログラマの方から、畑違いなんでしょうが「動画ファイルからは時間を取得することは出来ない」と断言して言われたことがあります。

私はプログラマでは無いですが、私がしがない3DCG屋だった頃、所属していた企業で私以外は、全員プログラマだった現場で働いておりました。
その当時は、Windowのマルチメディア環境が今のように整っていない時代で、動画を「滑らかに」「フルスクリーンで」再生するには、DirectDraw(DirectXの2D描画API) を直接叩いて、ビデオフレームを描画するのが一般的でした。
この頃には既に動画圧縮の技術も身に着けておりました。

その際に、プログラムの概念、プログラムで何が出来て何が出来ないのかなど様々な事を学んできているので、ファイルフォーマットの中にどういう情報が含まれているのかなどもざっくりですが把握しています。
また、映像制作に関しては3DCG屋がスタートだったので、アニメーションを制作して動画にするには、1秒約30枚の静止画を出力して、30枚を1秒にし、必要な尺の動画を作ることも日常でした。

こう言った経緯から、「動画ファイルにフレームの枚数が取得できるから計算で時間は取得出来る」と頭で理解出来てしまうのですが、プログラマはプログラマでも畑違いだと仕様を目にしたことがないと中々難しいようで、説明に困ったことがあります。
出来ないんだったら、そもそも動画のプレイヤーって存在しないことになっちゃうんですよ。

その時言われたのはこうでした。
「動画ファイルから取得出来ても全部の枚数だ」

「だったら、フレームレートが分かれば計算で時間が分かるじゃないですか。」
とお答えしたのですが、恐らく"フレームレート"が分からなかったようで、ご説明差し上げたら「あぁだったら出来るね!」という形で収束したことがありました。

という具合に、精通した知識があれば分かることでも、一般ではそこまで深く「これはどうやって作られてるんだろう」と考えることがないので、既にある物を使用するのが普通だと思いますが、我々クリエーターは最低でも知っておいたら、制作時に色々"時短"になるという話が結構多くあります。

業務やプロジェクトで「少し助けが欲しい」と感じることはありませんか?
小さなご相談から大きな課題まで、私たちが全力でお手伝いします。
ぜひお気軽にお問い合わせください。

技術情報ネタ カテゴリ一覧

よかったらシェアしてね!
目次