バイブコーディングに挑戦してみた件

バイブコーディングといっても電マを魔改造することではございません。ざっくり言うとAIに日本語(自然言語)で指示を出してプログラムを書くというプログラミング手法です。

ところで旅行やイベントで撮ったものや中国の(文字通りの)地下DVDやインターネットなどで収集した動画ファイルってどこのご家庭にもありますよね。そんなちょっと前に作られた動画ファイルは普通H.264と呼ばれている方法で圧縮されていると思いますが、一昔前の規格なので圧縮効率が悪いとされています。

一方で今どきの動画ファイルはH.265と呼ばれるもので圧縮効率が良い反面、エンコードに時間がかかるという弱点があります。

最近、家にサーバーを建てたので昔から溜め込んだ動画ファイルを一気に変換してしまおうと思いましたが、結構複雑な処理になって自分でスクリプトを書くのがめんどくさいなと思っていました。そんなわけでAIでスクリプトを書いてもらうことにしました。

とりあえずプロンプトはこんな感じ。

macのffmpeg8.0でフォルダを再起的に動画を検索してh.265にエンコードし直すスクリプトを生成して。画質とエンコード速度は中程度で。圧縮効率を重視してください。音声はaac_atでhe-aacv2でステレオ32kbpsにする。解像度やフレームレートなどは変更しない。元のフォルダ構造を保ったまま別の場所にコピーする。元のフォルダには動画以外のファイルがあるかもしれないが無視する。

するとこんな感じのPythonコードを一瞬で書いてくれます。

長いので省略。
import os
import subprocess

# 元動画のフォルダパス(適宜変更)
input_base_dir = '/path/to/source_folder'
# エンコード結果保存先フォルダパス(適宜変更)
output_base_dir = '/path/to/output_folder'

# 対象動画拡張子(小文字で指定)
video_extensions = {'.mp4', '.mov', '.mkv', '.avi', '.flv', '.wmv', '.ts'}

# FFmpegコマンドテンプレート
# libx265のプリセットmedium(中程度速さと画質のバランス)
# crf=28で圧縮効率重視の中画質設定
# 音声はHE-AACv2、ステレオ32kbps
ffmpeg_cmd_template = (
    'ffmpeg -i "{input_file}" '
    '-c:v libx265 -preset medium -x265-params crf=28 '
    '-c:a aac -profile:a heaac_v2 -b:a 32k -ac 2 '
    '-y "{output_file}"'
)

def process_folder(input_folder, output_folder):
    if not os.path.exists(output_folder):
        os.makedirs(output_folder)

    for entry in os.scandir(input_folder):
        if entry.is_dir():
            # サブフォルダは再帰処理
            process_folder(entry.path, os.path.join(output_folder, entry.name))
        elif entry.is_file():
            ext = os.path.splitext(entry.name)[1].lower()
            if ext in video_extensions:
                input_file = entry.path
                output_file = os.path.join(output_folder, entry.name)

                cmd = ffmpeg_cmd_template.format(input_file=input_file, output_file=output_file)
                print(f'Processing: {input_file}')
                subprocess.run(cmd, shell=True, check=True)

# 実行例(使用時にコメントアウトを外してパスを修正のこと)
# process_folder(input_base_dir, output_base_dir)

すげー!けど、なんかしっくりきません。せっかくだから今話題のGPT-5 Thinkingに単純に「リファクタリングして」と言ったら一発で良い感じに直してくれました。GPT-5 Thinkingによるとこんな感じに直してくれたようです。本当にすげーというかこれが本業の人は大変だろうなと思いました。

要件は維持したまま、安全性・保守性・拡張性を高めるためにCLI化、shell=Trueの排除、出力先ディレクトリの自動生成、CSVログ化、スキップ/上書き制御、並列処理、ドライランを加えてリファクタリングしたコードを提示する。HE-AAC v2 は Apple AudioToolbox の aac_at か Fraunhofer の libfdk_aac で正式対応のため、利用可能なAACエンコーダを自動検出して最適な設定を選ぶフォールバックを実装している。

改善点
subprocessは文字列連結とshell=Trueをやめ、引数リストで安全に呼び出すよう変更したため、スペースやメタ文字を含むパスでも安全に動作する。

HE-AAC v2のエンコードは aac_at または libfdk_aac が前提のため、ffmpeg -encoders から検出し、無ければネイティブaacにフォールバックしてプロファイル指定を省略する設計にした。

ログをCSVで記録し、元サイズ・出力サイズ・圧縮率・結果・メッセージを追跡できるようにし、--dry-run や --overwrite、--workers による並列処理も追加した。

せっかくですので公開します。自己責任でご利用ください。

これも長いのでコードを見たい場合はクリックしてください。
#!/usr/bin/env python3
"""
フォルダ配下の動画を再帰的に走査し、H.265(HEVC) + AACで再エンコードするスクリプト。
- .avi/.flv/.wmv 入力時は出力拡張子を .mp4 に変更し、同名 .mp4 が存在すれば衝突としてスキップ
- Apple互換性向上のため、映像を再エンコードする場合は -tag:v hvc1 を付与(コピー時は付与しない)
- 音声: HE-AAC v2 が可能なら使用(aac_at は数値プロフィール 28、libfdk_aac は aac_he_v2)、なければネイティブ aac
- 音声サンプルレートは常に 44.1 kHz に変換
- 映像/音声のストリームコピー(パススルー)オプションを追加
- 出力の上書き可否、並列実行、ドライラン、CSVログ出力に対応
"""

import argparse
import csv
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import subprocess

# 処理対象とする動画拡張子(小文字)
VIDEO_EXTS = {'.mp4', '.mov', '.mkv', '.avi', '.flv', '.wmv', '.ts'}

# 入力がこの拡張子群のときは、出力拡張子を .mp4 に変更
TRANSCODE_TO_MP4_EXTS = {'.avi', '.flv', '.wmv'}

def detect_aac_encoder():
    """
    利用可能なAACエンコーダを検出して (encoder, supports_he_v2) を返す。
    優先度: aac_at(macOS) -> libfdk_aac -> aac(native)。
    """
    try:
        res = subprocess.run(
            ["ffmpeg", "-hide_banner", "-encoders"],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            check=True
        )
        txt = res.stdout.lower()
    except Exception:
        return ("aac", False)

    if " aac_at " in txt or txt.strip().endswith("aac_at"):
        return ("aac_at", True)
    if " libfdk_aac " in txt or " libfdk_aac" in txt:
        return ("libfdk_aac", True)
    if "\naac " in txt or " aac " in txt or txt.strip().endswith("aac"):
        return ("aac", False)
    return ("aac", False)

def build_ffmpeg_cmd(
    input_file: Path,
    output_file: Path,
    preset: str,
    crf: int,
    a_bitrate: str,
    overwrite: bool,
    aac_encoder: str,
    he_v2: bool,
    copy_video: bool,
    copy_audio: bool
):
    """
    入力/出力、x265のCRF・プリセット、AACエンコーダ/ビットレート等から
    安全な引数リスト形式でffmpegコマンドを構築して返す。
    """
    args = [
        "ffmpeg",
        "-i", str(input_file),
    ]

    # 映像: パススルー or x265
    if copy_video:
        args += ["-c:v", "copy"]  # 映像ストリームコピー
    else:
        # 映像エンコード設定(解像度・フレームレートは変更しない)
        args += [
            "-c:v", "libx265",
            "-preset", preset,
            "-x265-params", f"crf={crf}",
            "-tag:v", "hvc1",  # Apple互換性向上(エンコード時のみ付与)
        ]

    # 音声: パススルー or AAC系エンコード(44.1kHz固定)
    if copy_audio:
        args += ["-c:a", "copy"]
    else:
        if aac_encoder == "aac_at":
            # aac_at は profile 名称を受け付けないため数値指定(HEv2=28, HEv1=4)
            a_args = ["-c:a", "aac_at"]
            if he_v2:
                a_args += ["-profile:a", "28"]
            a_args += ["-b:a", a_bitrate, "-ac", "2", "-ar", "44100"]
        elif aac_encoder == "libfdk_aac":
            a_args = ["-c:a", "libfdk_aac"]
            if he_v2:
                a_args += ["-profile:a", "aac_he_v2"]
            a_args += ["-b:a", a_bitrate, "-ac", "2", "-ar", "44100"]
        else:
            # ネイティブ aac(HEv2は安定提供しないため profile 指定なし)
            a_args = ["-c:a", "aac", "-b:a", a_bitrate, "-ac", "2", "-ar", "44100"]
        args += a_args

    # 出力ファイルの上書き可否
    args += ["-y" if overwrite else "-n"]

    # 出力パス
    args += [str(output_file)]
    return args

def ensure_parent(dirpath: Path):
    """親ディレクトリを(存在しなければ)再帰的に作成する。"""
    dirpath.mkdir(parents=True, exist_ok=True)

def human_bytes(n: int) -> str:
    """バイトサイズを可読な単位(KB/MB/GB)に整形して返す。"""
    for unit in ["bytes", "KB", "MB", "GB", "TB"]:
        if n < 1024.0 or unit == "TB":
            return f"{n:.0f} {unit}" if unit == "bytes" else f"{n:.1f} {unit}"
        n /= 1024.0
    return f"{n} bytes"

def process_one(
    src: Path,
    src_root: Path,
    dst_root: Path,
    preset: str,
    crf: int,
    a_bitrate: str,
    overwrite: bool,
    dry_run: bool,
    aac_encoder: str,
    he_v2: bool,
    copy_video: bool,
    copy_audio: bool
):
    """
    単一の動画ファイルをエンコード/リマックスし、結果サマリ(辞書)を返す。
    - .avi/.flv/.wmv は出力拡張子を .mp4 に変更(衝突は無条件スキップ)
    - overwrite=False かつ出力が存在する場合はスキップ
    - dry_run=True の場合は実行せずコマンドのみ返す(表示は従来形式)
    """
    # 入力ルートからの相対パスを維持して出力先パスを決定
    rel = src.relative_to(src_root)
    dst = dst_root / rel

    # 入力拡張子に応じて .mp4 へ変更(衝突は無条件スキップ)
    ext = src.suffix.lower()
    rename_to_mp4 = ext in TRANSCODE_TO_MP4_EXTS
    if rename_to_mp4:
        dst = dst.with_suffix(".mp4")

    ensure_parent(dst.parent)

    # 変更で衝突する場合は overwrite の有無に関係なくスキップ
    if rename_to_mp4 and dst.exists():
        return {
            "input": str(src),
            "output": str(dst),
            "original_bytes": src.stat().st_size,
            "compressed_bytes": dst.stat().st_size,
            "ratio": (dst.stat().st_size / src.stat().st_size) if src.stat().st_size else 0.0,
            "status": "skipped_collision",
            "message": "Name collision after extension change; skipped"
        }

    # 通常の上書き抑止(非変更ケース)
    if not rename_to_mp4 and (not overwrite) and dst.exists():
        return {
            "input": str(src),
            "output": str(dst),
            "original_bytes": src.stat().st_size,
            "compressed_bytes": dst.stat().st_size,
            "ratio": (dst.stat().st_size / src.stat().st_size) if src.stat().st_size else 0.0,
            "status": "skipped_exists",
            "message": "Output exists; skipped"
        }

    cmd = build_ffmpeg_cmd(
        input_file=src,
        output_file=dst,
        preset=preset,
        crf=crf,
        a_bitrate=a_bitrate,
        overwrite=True,  # 上書き有無はPython側で制御するためffmpegには常に -y
        aac_encoder=aac_encoder,
        he_v2=he_v2,
        copy_video=copy_video,
        copy_audio=copy_audio
    )

    if dry_run:
        # 表示は修正前(従来)の " ".join(cmd) に戻す
        return {
            "input": str(src),
            "output": str(dst),
            "original_bytes": src.stat().st_size,
            "compressed_bytes": None,
            "ratio": None,
            "status": "dry_run",
            "message": " ".join(cmd)
        }

    try:
        subprocess.run(cmd, check=True)
        out_size = dst.stat().st_size if dst.exists() else 0
        in_size = src.stat().st_size
        ratio = (out_size / in_size) if in_size else 0.0
        return {
            "input": str(src),
            "output": str(dst),
            "original_bytes": in_size,
            "compressed_bytes": out_size,
            "ratio": ratio,
            "status": "ok",
            "message": ""
        }
    except subprocess.CalledProcessError as e:
        return {
            "input": str(src),
            "output": str(dst),
            "original_bytes": src.stat().st_size,
            "compressed_bytes": None,
            "ratio": None,
            "status": "error",
            "message": f"ffmpeg failed (code={e.returncode})"
        }

def iter_videos(root: Path):
    """ルート配下から対象拡張子の動画ファイルを再帰的に列挙するジェネレータ。"""
    for p in root.rglob("*"):
        if p.is_file() and p.suffix.lower() in VIDEO_EXTS:
            yield p

def write_csv_header_if_needed(csv_path: Path):
    """CSVが未作成ならヘッダー行を出力して作成する。"""
    if not csv_path.exists():
        ensure_parent(csv_path.parent)
        with csv_path.open("w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["input", "output", "original_bytes", "compressed_bytes", "ratio", "status", "message"])

def append_csv(csv_path: Path, row: dict):
    """エンコード結果1件をCSVに追記する。"""
    with csv_path.open("a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow([
            row.get("input", ""),
            row.get("output", ""),
            row.get("original_bytes", ""),
            row.get("compressed_bytes", ""),
            f"{row.get('ratio', ''):.3f}" if isinstance(row.get("ratio"), float) else "",
            row.get("status", ""),
            row.get("message", ""),
        ])

def main():
    """引数を解釈して一括処理を実行するエントリポイント。"""
    ap = argparse.ArgumentParser(
        description="Recursively re-encode videos to H.265 and AAC; .avi/.flv/.wmv -> .mp4; Apple hvc1 tag; pass-through options"
    )
    ap.add_argument("--input", required=True, help="Source folder")              # 入力フォルダ
    ap.add_argument("--output", required=True, help="Destination base folder")   # 出力フォルダ
    ap.add_argument("--preset", default="medium", help="x265 preset (e.g., slow, medium, fast)")  # x265プリセット
    ap.add_argument("--crf", type=int, default=28, help="x265 CRF (lower=better)")                 # x265品質
    ap.add_argument("--audio-bitrate", default="32k", help="Audio bitrate (e.g., 32k)")            # 音声ビットレート
    ap.add_argument("--log-csv", default="compression_log.csv", help="CSV log path")               # CSVログパス
    ap.add_argument("--overwrite", action="store_true", help="Overwrite existing outputs (non-renamed cases)")  # 上書き
    ap.add_argument("--workers", type=int, default=1, help="Parallel workers")                      # 並列ワーカー数
    ap.add_argument("--dry-run", action="store_true", help="Print commands without running")        # ドライラン
    ap.add_argument("--copy-video", action="store_true",
                    help="Copy video stream without re-encoding (-c:v copy)")  # 映像パススルー
    ap.add_argument("--copy-audio", action="store_true",
                    help="Copy audio stream without re-encoding (-c:a copy)")  # 音声パススルー
    args = ap.parse_args()

    src_root = Path(args.input).expanduser().resolve()
    dst_root = Path(args.output).expanduser().resolve()
    ensure_parent(dst_root)

    # 利用可能なAACエンコーダの自動検出(HE-AAC v2対応可否を含む)
    aac_encoder, he_v2 = detect_aac_encoder()
    if not he_v2:
        print(f"[warn] HE-AAC v2 encoder not detected; falling back to '{aac_encoder}' without profile", file=sys.stderr)

    # CSVログの準備(ヘッダー生成)
    log_csv = Path(args.log_csv).expanduser()
    write_csv_header_if_needed(log_csv)

    # 対象ファイルの収集
    files = list(iter_videos(src_root))
    if not files:
        print("No target files found.")
        return

    # 並列実行
    workers = max(1, args.workers)
    with ThreadPoolExecutor(max_workers=workers) as ex:
        futs = [
            ex.submit(
                process_one,
                f, src_root, dst_root,
                args.preset, args.crf, args.audio_bitrate,
                args.overwrite, args.dry_run,
                aac_encoder, he_v2,
                args.copy_video, args.copy_audio
            )
            for f in files
        ]

        # 完了順に結果を処理し、標準出力とCSVに記録
        for fu in as_completed(futs):
            row = fu.result()
            status = row["status"]
            src = row["input"]

            if status == "ok":
                in_h = human_bytes(row["original_bytes"])
                out_h = human_bytes(row["compressed_bytes"])
                ratio = row["ratio"]
                print(f"[ok] {src} | {in_h} -> {out_h} | ratio={ratio:.3f}")
            elif status == "skipped_collision":
                print(f"[skip] {src} | name collision after extension change")
            elif status == "skipped_exists":
                print(f"[skip] {src} | output exists")
            elif status == "dry_run":
                print(f"[dry-run] {src}")
                print(row["message"])
            else:
                print(f"[error] {src} | {row.get('message','')}", file=sys.stderr)

            # CSVログへ追記
            append_csv(log_csv, row)

if __name__ == "__main__":
    # CLIエントリポイント
    main()

使い方もGPT-5に書いてもらいました。

つーか、書いてて気づいたんですが、ここまで本格的なスクリプトだったら独立したページにしたほうがいいですね。というわけで下記リンク参照ということで。

ここまでやって分かったことは、バイブコーディングにPythonの知識はほぼ不要で、むしろAIが書いたコードは基本的にバグってるんで、結局はデバッグ力が必要ということですね。最初はGeminiに書かせたんですが、ハルシネーション強めでかなりイマイチだったのでGPT-5に切り替えた経緯があります。これはGeminiのバージョンアップに期待。

というわけで、最近のプログラミング事情はAIがすごいことになってて、アメリカで新卒の雇用が悪化するのも納得のプログラミング力といったところでしょう。まじすごいので一回やってみることをお勧めします。

コメント

タイトルとURLをコピーしました