🎤

YouTube作業BGM動画作成サービスの舞台裏〜第4章 🎵 YouTubeのBGM動画生成システム - 音源処理の裏側大公開!

に公開

「あのね、60分間のBGM動画を作るのに、1時間もパソコンの前で待ってられないよね?」という切実な悩みを抱えたことはありませんか?今回は、AWS Lambdaとffmpegのパワーをお借りして、自動的にYouTube用のBGM動画を生成するシステムの内部構造をバッサリと解説します!

1. 音源連結処理 - 複数の曲をシームレスにつなぐ魔法 🧙‍♂️

まずは複数の音楽ファイルを一つにつなぐ処理から見ていきましょう。これはまるで音楽のレールをつないでいくような作業です。

def concatenate_audio_files(audio_paths, output_path):
    """複数の音声ファイルを連結(通称:音楽電車製造機)"""
    # 一時的なファイルリスト(=乗車予定表)を作成
    file_list_path = os.path.dirname(output_path) + '/filelist.txt'
    with open(file_list_path, 'w') as f:
        for audio_path in audio_paths:
            f.write(f"file '{audio_path}'\n")
    
    # ffmpegコマンドで連結(さぁ、音楽電車の出発です!)
    subprocess.run([
        'ffmpeg',
        '-f', 'concat',
        '-safe', '0',
        '-i', file_list_path,
        '-c', 'copy',
        output_path
    ], check=True)
    
    return output_path

処理のポイント

  1. ファイルリストの作成

    • ffmpegに「この曲、次はこの曲、その次はこの曲...」と順番を教えるための予定表を作ります
    • タイピングが面倒なので、Pythonにお願いして自動生成!
  2. ffmpegコマンドオプションの大翻訳

    • -f concat:「複数のファイルをつなげてね」という合図
    • -safe 0:「ちょっと危険かもしれないけど、大丈夫、信じてる!」という冒険心
    • -c copy:「音質を劣化させないでそのままコピーしてね」という贅沢な要求
  3. 効率性へのこだわり

    • エンコード/デコードせずコピーするだけなので、ラーメンを注文してから届くまでの間に処理が終わるレベルの速さ!
    • AWS Lambdaの料金を節約できるという嬉しいおまけ付き

2. 音源ループ処理 - 無限に続く音楽の錯覚を生み出す技 🔄

連結した音楽が目標時間に達していない場合、DJ風に曲をループさせるテクニックが必要です。例えば5分の曲を60分にしたい場合、単純に12回繰り返せばいいですね!

def loop_audio_file(input_path, input_duration, target_duration, output_path):
    """音声ファイルを指定時間までループさせる(DJ botの誕生)"""
    loop_count = math.ceil(target_duration / input_duration)
    
    # ffmpegのinputオプションを複数回指定(同じ曲のリクエストが何回も来た状態)
    input_args = []
    for i in range(loop_count):
        input_args.extend(['-i', input_path])
    
    # フィルタの構築(DJのミキシングテクニック)
    filter_complex = ''
    for i in range(loop_count):
        filter_complex += f'[{i}:0]'
    filter_complex += f'concat=n={loop_count}:v=0:a=1[out]'
    
    # ffmpegコマンドでループ(さぁ、エンドレスパーティーの始まりです)
    subprocess.run([
        'ffmpeg',
        *input_args,
        '-filter_complex', filter_complex,
        '-map', '[out]',
        '-t', str(target_duration),
        '-c:a', 'libmp3lame',
        '-q:a', '4',
        output_path
    ], check=True)
    
    return output_path

処理のポイント

  1. ループ回数の計算

    • 「30分の動画に対して3分の曲だと...」と頭を抱える代わりに、コンピュータに計算してもらいましょう
    • math.ceil()で切り上げるのは、「足りないより余る方がマシ」という調理の原則と同じ
  2. filter_complexの動的構築

    • これは「音楽の設計図」のようなもの。ffmpegに「どうやって音を組み合わせるか」を教えます
    • 正直言って、初めて見たときは暗号にしか見えないでしょう。でも大丈夫、慣れれば読めるように...なるかも?

フィルターコンプレックスとは?

「フィルターコンプレックス」という言葉は難しく聞こえますが、簡単に言うと「複数の音声や映像を組み合わせて加工する方法を説明する図」のようなものです。

中学生向けに例えると:

  1. 水道管のようなもの:複数の音や映像が流れる管を想像してください

  2. 分岐と合流:その管が分かれたり、合流したりします

  3. 途中に仕掛け:管の途中には「音を大きくする」「色を変える」などの仕掛けがあります

  4. 設計図:これら全部をどうつなげるかの設計図がフィルターコンプレックスです

  5. 正確な時間調整

    • -tオプションで「この辺でフェードアウトして」と指示
    • 60分1秒の動画にする人はいないので、ピッタリ指定時間で切り落とします
  6. 音質の最適化

    • libmp3lameコーデックは音楽業界のベテラン選手。信頼して任せましょう
    • -q:a 4は「そこそこいい音質でファイルサイズも大きくしすぎないで」というわがままな注文

3. VideoGenerator関数での処理フロー - 全体の流れを把握 🎬

これらの音源処理関数は、Lambda関数内で以下のような流れで呼び出されます。まるでレシピのような手順書です:

def process_video(job_id, audio_files, image_file, config):
    try:
        # 状態を処理中に更新(「調理中です、お待ちください」の札を掲げる)
        update_status(job_id, 'processing', 5, '素材のダウンロード中')
        
        # 一時作業ディレクトリの作成(作業台の準備)
        with tempfile.TemporaryDirectory() as temp_dir:
            # S3から素材をダウンロード(冷蔵庫から材料を取り出す)
            image_path = download_from_s3(image_file, temp_dir)
            audio_paths = [download_from_s3(audio_file, temp_dir) for audio_file in audio_files]
            
            update_status(job_id, 'processing', 10, '音声ファイルの連結中')
            
            # 音声ファイルの連結(材料を混ぜ合わせる)
            concatenated_audio = os.path.join(temp_dir, 'concatenated.mp3')
            concatenate_audio_files(audio_paths, concatenated_audio)
            
            update_status(job_id, 'processing', 30, '音声のループ処理中')
            
            # 総再生時間の取得とループ処理(足りなければ材料を増やす)
            audio_duration = get_audio_duration(concatenated_audio)
            total_duration_seconds = config.get('totalDuration', 60) * 60  # 分から秒に変換
            
            # ループが必要な場合は音声ファイルをループさせる
            if config.get('loopAudio', True) and audio_duration < total_duration_seconds:
                looped_audio = os.path.join(temp_dir, 'looped.mp3')
                loop_audio_file(concatenated_audio, audio_duration, total_duration_seconds, looped_audio)
                audio_file_for_video = looped_audio
            else:
                audio_file_for_video = concatenated_audio
            
            update_status(job_id, 'processing', 50, '動画生成中')
            
            # 解像度とビットレートの設定(盛り付けの準備)
            resolution = config.get('resolution', {'width': 1280, 'height': 720})
            quality_settings = get_quality_settings(config.get('quality', 'medium'))
            
            # 動画の生成(いよいよ調理完了!)
            output_video = os.path.join(temp_dir, 'output.mp4')
            create_video(image_path, audio_file_for_video, output_video, resolution, quality_settings)
            
            # ... 以下省略 ...

処理フローのポイント

  1. 段階的な処理と進捗報告

    • 各処理ステップで「今ここまで完了しました!」と報告。ユーザーを「あとどれくらいかかるの...」という不安から解放します
    • 動画処理は意外と時間がかかるので、ユーザーを飽きさせない工夫が必要です
  2. 条件分岐による最適化

    • すでに十分な長さの音楽ならループ処理はスキップ。無駄な処理は省いてさっさと次へ進みます
    • 「必要なときだけ」が効率化の基本。余計なことはしません
  3. 一時ディレクトリの活用

    • with tempfile.TemporaryDirectory() as temp_dir: は「作業が終わったら自動的に片付けてね」という素晴らしい魔法
    • Lambda関数の/tmp領域は容量制限があるので、使い終わったファイルはきちんと削除する習慣が大切です

4. 処理状況の管理と通知(ポーリング方式) 📊

動画生成は時間のかかる処理ですので、ユーザーに進捗状況をリアルタイムで伝えることが重要です。「まだかなー、まだかなー」というユーザーの気持ちを和らげる仕組みです。

DynamoDBによる状態管理

処理状態はDynamoDBテーブルに保存され、以下のような情報が含まれます:

JobStatusTable:
  - jobId: プライマリキー(一意のジョブID)
  - status: 処理状態('queued', 'processing', 'completed', 'failed')
  - progress: 進捗率(0-100)
  - message: 現在実行中の処理内容のメッセージ
  - createdAt: 作成タイムスタンプ
  - updatedAt: 最終更新タイムスタンプ

Lambda関数内では、処理の各段階でこんな感じで状態を更新します:

def update_status(job_id, status, progress, message=None):
    """処理状態の更新(「調理中...50%完了」という看板の更新)"""
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table(STATUS_TABLE)
    
    # 更新内容の設定
    update_expr = "SET #status = :status, #progress = :progress, #updatedAt = :updated_at"
    expr_attr_names = {
        '#status': 'status',
        '#progress': 'progress',
        '#updatedAt': 'updatedAt'
    }
    expr_attr_values = {
        ':status': status,
        ':progress': progress,
        ':updated_at': int(time.time())
    }
    
    # メッセージがあれば追加
    if message:
        update_expr += ", #message = :message"
        expr_attr_names['#message'] = 'message'
        expr_attr_values[':message'] = message
    
    # DynamoDBの更新
    table.update_item(
        Key={'jobId': job_id},
        UpdateExpression=update_expr,
        ExpressionAttributeNames=expr_attr_names,
        ExpressionAttributeValues=expr_attr_values
    )

フロントエンドでのポーリング処理

フロントエンドでは、React Hooksを使用して「そろそろできた?」と定期的にサーバーに問い合わせます:

// ProcessingPage.js
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { checkStatus } from '../services/api';

export const ProcessingPage = () => {
  const router = useRouter();
  const { jobId } = router.query;
  const [status, setStatus] = useState({ status: 'processing', progress: 0, message: '処理中...' });
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!jobId) return;

    // ポーリング間隔(ミリ秒)- 3秒ごとに「まだ~?」と聞く
    const POLLING_INTERVAL = 3000;
    let intervalId;

    // ポーリング関数 - 進捗確認の問い合わせ
    const pollStatus = async () => {
      try {
        const result = await checkStatus(jobId);
        setStatus(result);
        
        // 処理が完了または失敗した場合
        if (result.status === 'completed') {
          clearInterval(intervalId);
          // 結果ページへリダイレクト - 「できたよ!見に来て!」
          router.push(`/result/${jobId}`);
        } else if (result.status === 'failed') {
          clearInterval(intervalId);
          setError(result.message || 'エラーが発生しました');
        }
      } catch (err) {
        setError('ステータス取得中にエラーが発生しました');
        clearInterval(intervalId);
      }
    };

    // 初回の状態確認
    pollStatus();
    // 定期的なポーリングを開始
    intervalId = setInterval(pollStatus, POLLING_INTERVAL);

    // コンポーネントのクリーンアップ時にポーリングを停止
    return () => {
      if (intervalId) clearInterval(intervalId);
    };
  }, [jobId, router]);

  // プログレスバーやステータスメッセージの表示
  return (
    <div>
      <h1>動画生成中</h1>
      <div className="progress-bar">
        <div 
          className="progress-fill" 
          style={{ width: `${status.progress}%` }}
        ></div>
      </div>
      <p>{status.message}</p>
      <p>進捗: {status.progress}%</p>
      {error && <p className="error">{error}</p>}
    </div>
  );
};

React Hooksの基本

React Hooksは、関数コンポーネント内で状態管理やライフサイクル機能を使えるようにするReactの機能です。Hooksが登場する前は、クラスコンポーネントを書く必要があり、「this」という落とし穴がそこかしこに潜んでいました。

上記のコードで使われている主要なHooksは以下の通りです:

  1. useState: コンポーネント内で状態を管理するためのHook

    const [status, setStatus] = useState({ status: 'processing', progress: 0, message: '処理中...' });
    
    • statusは現在の状態を保持する変数(冷蔵庫)
    • setStatusは状態を更新するための関数(冷蔵庫に食材を入れ替える人)
    • 引数は初期値(最初に冷蔵庫に入れておくもの)
  2. useEffect: 副作用(データフェッチ、DOM操作など)を扱うためのHook

    useEffect(() => {
      // ここに実行したい処理を書く(「コンポーネントが表示されたらやること」)
      return () => {
        // クリーンアップ関数(「コンポーネントが消える前にやること」)
      };
    }, [依存配列]); // 配列内の値が変わった時だけ処理が再実行される
    

この例では、useEffect内でポーリング処理を実装し、3秒ごとに「まだ終わってないの?」とサーバーに聞いています。処理が完了すると「おっ、できた!」と結果ページにリダイレクトし、エラーが発生した場合は「あれ?おかしいぞ?」とエラーメッセージを表示します。

ポーリング方式を採用した理由

動画生成サービスでは、WebSocketではなくポーリング方式を採用していますが、これには以下のような理由があります:

  1. 実装の複雑さとコスト効率:

    • ポーリング方式は「こんにちは、終わりました?」と定期的に聞くだけのシンプルな仕組み
    • WebSocketは「常時接続の専用電話線」を用意するようなもので、初期構築が複雑
    • シンプルな実装 = バグが少ない = 夜も安心して眠れる
  2. ユースケースの特性:

    • 動画生成は「毎秒更新が必要なチャット」ではなく「数分かかる調理」のようなもの
    • 数秒ごとの更新で十分で、「0.1秒単位のリアルタイム性」は必要ない
    • スマホの配送状況を1秒ごとに確認しないのと同じ理由
  3. サーバーレスアーキテクチャとの親和性:

    • Lambda+API Gateway+DynamoDBという組み合わせは「イベント駆動」が得意
    • WebSocketの長時間接続維持は、時間制限のあるLambdaとは相性が悪い
    • 「できる」と「相性が良い」は別の話。無理して複雑にするよりシンプルに
  4. 段階的な開発戦略:

    • まずは「動くもの」を素早く作り、後から改良するアプローチ
    • 「完璧な設計」を目指すよりも「使えるサービス」を先に提供
    • 「まだ誰も使っていないサービス」に高度な機能を入れてもムダになりがち
  5. 信頼性とエラー回復:

    • ポーリングは「一回聞き逃しても、また後で聞けばいい」という気楽さ
    • WebSocketは接続が切れたときの再接続や状態回復が面倒
    • シンプルな仕組みほど障害時に復旧しやすい

WebSocketを使った発展的実装

将来的な改善としては、WebSocketを使った双方向通信が考えられます:

  1. AWS API Gateway WebSocket APIを構築
  2. DynamoDB Streamsで状態変更を検知
  3. Lambda関数でWebSocketクライアントに通知

この方法なら、状態が変わった時だけクライアントに通知できるため、より効率的でリアルタイム性が高くなります。しかし、初期段階では複雑さと開発コストを考慮すると、ポーリング方式が実用的な選択となっています。特にトラフィックが少ないうちは、ポーリングによる余分なリクエストも大きな問題にはなりません。「シンプルだけど動く」が最初のモットーです。

5. 技術的な工夫とポイント 🛠️

Lambda環境での制約への対応

  1. メモリとディスク容量の制限

    • Lambdaの/tmpディレクトリは最大10.5GBまで。4K動画を扱うなら注意が必要です
    • 処理中の進捗を細かく更新し、「フリーズした?」と思われないようにする心遣い
    • 一時ファイルは使い終わったらすぐ削除。片付けの良い料理人のように
  2. 実行時間の最適化

    • 可能な限り-c copyオプションを使って再エンコードを回避。時間は金なり!
    • 「完璧な処理」より「十分な品質の処理」を優先。YouTubeのBGM動画にハリウッド映画級の処理は不要

ffmpegの効率的な使用

  1. コーデック選択

    • 連結時は可能な限りコピーモードを使用。「切って貼るだけなら、素材を劣化させない」が鉄則
    • 最終出力時のみ必要に応じて再エンコード。ここぞというときだけ力を使う
  2. フィルタグラフの活用

    • 複雑な処理も単一のffmpegコマンドで実行。これぞプロの技
    • 複数のプロセス起動を避け、オーバーヘッドを削減。余計な待ち時間はカット
  3. エラーハンドリング

    • subprocess.run()check=Trueオプションで、ffmpegのエラーを即座に検出
    • 問題が発生したら早めに気づいて対応。「後で気づく」よりずっと良い

付録:フィルターコンプレックスを理解する

ffmpegのフィルターコンプレックスは、音声や映像の加工方法を指定する仕組みですが、初めて見ると「何この暗号?」と思うこと間違いなし。ここでは中学生でも理解できるよう簡単に説明します。

フィルターコンプレックスの基本

フィルターコンプレックスとは、簡単に言うと「複数の音や映像をどうつなげて、どう加工するかの設計図」です。

例えば、以下のようなフィルターコンプレックス:

[0:0][1:0][2:0]concat=n=3:v=0:a=1[out]

これは次のように分解できます:

  1. [0:0][1:0][2:0]:これは「材料」を示しています

    • [0:0]は「1番目の入力ファイルの音声」
    • [1:0]は「2番目の入力ファイルの音声」
    • [2:0]は「3番目の入力ファイルの音声」
  2. concat=n=3:v=0:a=1:これは「やりたいこと」を示しています

    • concatは「つなげる」という命令
    • n=3は「3つのものをつなげる」という指定
    • v=0:a=1は「映像はなし、音声だけつなげる」という指定
  3. [out]:これは「出力先」を示しています

    • 処理結果にoutという名前をつける

これを日本語で言うと:「3つの音声ファイルをつなげて、その結果を"out"という名前で出力します」という意味になります。

図で理解する

フィルターコンプレックスは下記のような図で表すとわかりやすくなります:

入力ファイル1の音声 [0:0] ──┐
入力ファイル2の音声 [1:0] ──┼─→ concat ──→ [out] 出力
入力ファイル3の音声 [2:0] ──┘

このように、フィルターコンプレックスは「どの素材を」「どのように処理して」「どこに出力するか」を指定する方法なのです。最初は難しく見えますが、慣れれば料理のレシピを読むように理解できるようになります!

まとめ - あなたもBGM動画クリエイターへの第一歩 🚀

YouTubeの作業用BGM動画を自動生成する仕組みの中核となる音源処理部分は、ffmpegの強力な機能を活用することで効率的に実装できます。特に複数の音源の連結とループ処理は、適切なコマンドオプションとフィルタグラフを組み合わせることで、高品質かつ高速な処理が可能になります。

この実装方法は、AWS Lambdaというサーバーレス環境でも十分な性能を発揮し、ユーザーに快適な動画生成体験を提供できます。「面倒な作業は自動化する」というエンジニアリングの醍醐味を味わいながら、コストパフォーマンスに優れたサービスを構築できるのは素晴らしいことですね。

あなたも今日からffmpegとAWSを駆使して、自分だけのBGM動画自動生成サービスを作ってみませんか?きっと新しい発見と達成感が待っていますよ!

Discussion