🚏

YouTube作業BGM動画作成サービスの舞台裏〜第7章 「待つのはつまらない」を「ワクワク」に変える:動画生成サービスの裏技〜

2025/03/29に公開

こんにちは、動画生成の裏側を覗き見する時間がやってきました!「YouTube作業BGM動画作成サービス」という長い名前のサービス(略して「ユーチューBGM」とでも呼びましょうか)では、60分の超長編動画を作るのに、それなりの時間がかかります。でも、「お待ちください...」の画面を見つめながら人生を無駄にするのはNO!というわけで、エンジニアたちが考えた「待ち時間を楽しくする秘策」をご紹介します。

いつの時代も「待つ」ことはフラストレーションの原因ですが(スーパーのレジ行列を思い出してください)、以下の工夫で「待ち時間」を「価値ある時間」に変えた裏側を見ていきましょう!

1. 非同期処理モデル — 「お待たせしません作戦」

「お客様、お料理の完成までずっとレストランに座っていてください」と言われたら嫌ですよね。システムは「お料理が届いたらご連絡します!」方式(技術的には非同期処理モデル)を採用しています。注文(処理リクエスト)を受けた後、「準備始めました!」という返事をすぐに返し、あなたを解放します。これで、動画が出来上がるまでの間、YouTubeで猫動画を見るなど、人生の大切な時間を有効活用できます。

# VideoGenerator Lambda関数の実装例
def lambda_handler(event, context):
    # ... 省略 ...
    
    # ジョブIDの生成
    job_id = str(uuid.uuid4())
    
    # 処理ステータスを「queued」として保存
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table(STATUS_TABLE)
    table.put_item(
        Item={
            'jobId': job_id,
            'status': 'queued',
            'progress': 0,
            # ... 省略 ...
        }
    )
    
    # 処理開始のトリガー(非同期処理)
    process_video(job_id, audio_files, image_file, config)
    
    # すぐにレスポンスを返す
    return {
        'statusCode': 202,  # Accepted
        'body': json.dumps({
            'jobId': job_id,
            'status': 'queued',
            'message': '動画生成処理がキューに追加されました'
        }),
        # ... 省略 ...
    }

2. 詳細な進捗表示 — 「何してるの見せちゃうぞ大作戦」

「配達中...」だけじゃ心配ですよね。「今、○○交差点を通過中です!」と知らせてくれるフードデリバリーのように、システムは「今、動画のここまでできました!」と詳細な進捗率を表示します。青いプログレスバーが少しずつ伸びていく様子は、なぜか見ていて妙に癒されるもの。「動いている!生きている!」という安心感をユーザーに提供し、「フリーズしたんじゃ...?」という恐怖心を払拭します。

// 進捗表示コンポーネント
const ProgressIndicator: React.FC<{ progress: number }> = ({ progress }) => {
  return (
    <div className="relative pt-1">
      <div className="flex mb-2 items-center justify-between">
        <div>
          <span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-blue-600 bg-blue-200">
            進捗
          </span>
        </div>
        <div className="text-right">
          <span className="text-xs font-semibold inline-block text-blue-600">
            {progress}%
          </span>
        </div>
      </div>
      <div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-blue-200">
        <div 
          style={{ width: `${progress}%` }} 
          className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500"
        ></div>
      </div>
    </div>
  );
};

3. リアルタイム進捗更新 — 「しつこく聞く大作戦」

子供の頃、「もう着く?もう着く?」と親に何度も聞いた経験はありませんか?実はシステムも同じことをしています。フロントエンドくんは3秒ごとに「もう終わった?もう終わった?」とバックエンドさんに小声で聞き続けています(技術的には「ポーリング」と呼ばれる仕組み)。ユーザーはボタンを押す必要なく、常に最新情報が画面に反映される魔法を体験できます。

// ProcessingPage.tsx
useEffect(() => {
  const intervalId = setInterval(async () => {
    try {
      // ステータス確認APIの呼び出し
      const status = await checkStatus(jobId);
      updateStatus({
        status: status.status,
        progress: status.progress,
        message: status.message || '',
      });
      
      // 処理完了時の処理
      if (status.status === 'completed') {
        clearInterval(intervalId);
        setResult(status.result);
        router.push(`/result/${jobId}`);
      } else if (status.status === 'failed') {
        clearInterval(intervalId);
        setError(status.message || '処理に失敗しました');
      }
    } catch (error) {
      console.error('Error checking status:', error);
    }
  }, 3000); // 3秒ごとに更新
  
  return () => clearInterval(intervalId);
}, [jobId]);

4. 処理ステップの可視化 — 「台所を見せちゃうぞ大作戦」

高級レストランのオープンキッチンのように、「今どんな作業をしているか」を見せることで信頼感が生まれます。「音声ファイルのダウンロード中...✓」「BGMを連結中...✓」「動画を焼き上げ中...→」といった具体的なステップを表示することで、単なる「33%完了」という数字だけでなく、「ああ、今は動画を焼き上げてるんだな」という安心感が生まれます。緑色のチェックマークが増えていくたびに、小さな達成感も味わえるおまけ付き!

<div class="mb-8 bg-gray-100 p-4 rounded-lg">
  <h3 class="font-semibold mb-2">処理ステップ:</h3>
  <ul class="space-y-2">
    <li class="flex items-center">
      <svg class="h-5 w-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
      </svg>
      <span>音声ファイルのダウンロード</span>
    </li>
    <!-- 完了ステップは緑のチェックマーク -->
    
    <li class="flex items-center">
      <svg class="h-5 w-5 text-blue-500 animate-pulse mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
      </svg>
      <span>動画生成中...</span>
    </li>
    <!-- 現在進行中のステップはアニメーション付き -->
    
    <li class="flex items-center text-gray-400">
      <svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
      </svg>
      <span>S3へのアップロード</span>
    </li>
    <!-- 未開始のステップはグレー表示 -->
  </ul>
</div>

5. 推定残り時間表示 — 「あとどれくらい?占い師大作戦」

「あと10分くらいかな〜」という適当な予測ではなく、AIによる高度な時間予測システム(...と言いたいところですが、実際はちょっと賢い計算方法)を使って、「あと約7分23秒」といった具体的な残り時間を表示します。これはまるで、「この動画、いつできるの?」と水晶玉を覗きこむ占い師のよう。でも、この占い師はちゃんとデータを基に予言しています!

過去データに基づく推定 — 「前例から学ぶ賢い子作戦」

「似たようなケースでどれくらいかかったかな?」とDynamoDBという名の巨大な記憶庫を検索します。「1080p・高画質・60分の動画は前回8分30秒かかったから、今回もそのくらいかな?」といった具体的なデータを活用:

# 類似設定の過去ジョブから推定時間を取得
def get_estimated_duration_from_history(config):
    dynamodb = boto3.resource('dynamodb')
    history_table = dynamodb.Table(HISTORY_TABLE)
    
    # 重要な設定要素でクエリ
    response = history_table.query(
        IndexName="ConfigurationIndex",
        KeyConditionExpression="#res = :res AND #quality = :quality",
        ExpressionAttributeNames={
            "#res": "resolution",
            "#quality": "quality"
        },
        ExpressionAttributeValues={
            ":res": f"{config['resolution']['width']}x{config['resolution']['height']}",
            ":quality": config['quality']
        },
        ScanIndexForward=False,  # 最新のデータから
        Limit=20  # 直近20件に限定
    )
    
    if response['Items']:
        # 動画長が近い項目を探す
        duration_target = config['totalDuration']
        similar_jobs = sorted(response['Items'], 
                             key=lambda x: abs(x['totalDuration'] - duration_target))
        
        # 最も近い5件の平均処理時間を取得
        if len(similar_jobs) >= 5:
            avg_duration = sum(job['processingTime'] for job in similar_jobs[:5]) / 5
            return avg_duration
        else:
            # データが少ない場合、単純平均
            avg_duration = sum(job['processingTime'] for job in similar_jobs) / len(similar_jobs)
            return avg_duration
    
    # 履歴がない場合のフォールバック推定
    return estimate_duration_from_parameters(config)

進捗率と経過時間に基づく動的推定 — 「小学生の算数大作戦」

3分で30%完了したら、100%完了するのに何分かかる?...という小学生でも解ける(かもしれない)比例計算を使います。「今の進み具合が続くと...」という仮定での計算なので、途中で難所にぶつかると予測が外れることもありますが、シンプルながら意外と優秀:

def estimate_remaining_time(job_id, progress):
    # 進捗が0%の場合は推定できない
    if progress <= 0:
        return None
    
    # 現在の時間と開始時間の差分
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table(STATUS_TABLE)
    response = table.get_item(Key={'jobId': job_id})
    
    if 'Item' not in response:
        return None
    
    start_time = response['Item'].get('createdAt', 0)
    current_time = int(time.time())
    elapsed_seconds = current_time - start_time
    
    # 進捗に基づく残り時間推定
    if progress > 0:
        estimated_total = elapsed_seconds / (progress / 100)
        remaining_seconds = estimated_total - elapsed_seconds
        
        # 負の値にならないように
        if remaining_seconds < 0:
            remaining_seconds = 0
            
        return remaining_seconds
    
    return None

処理負荷に応じた調整 — 「混雑状況お知らせ大作戦」

レストランが混雑していれば料理が出てくるのも遅くなりますよね。同様に、システムは「今、他にもたくさんの動画を作ってるから、ちょっと時間かかるよ」と正直に教えてくれます。CloudWatchという名の監視員が「今、システムの混雑度はこれくらいです」と報告してくれるので、それに基づいて待ち時間を調整します:

def adjust_estimate_for_system_load(estimated_seconds):
    # CloudWatchメトリクスからシステム負荷を取得
    cloudwatch = boto3.client('cloudwatch')
    
    # Lambda関数の同時実行数を取得
    response = cloudwatch.get_metric_statistics(
        Namespace='AWS/Lambda',
        MetricName='ConcurrentExecutions',
        Dimensions=[
            {
                'Name': 'FunctionName',
                'Value': 'videoGenerator'
            },
        ],
        StartTime=datetime.datetime.utcnow() - datetime.timedelta(minutes=5),
        EndTime=datetime.datetime.utcnow(),
        Period=60,
        Statistics=['Average']
    )
    
    # 同時実行数に基づく調整係数
    concurrent_executions = 1
    if response['Datapoints']:
        concurrent_executions = max(1, response['Datapoints'][-1]['Average'])
    
    # 実行数が多いほど処理時間は長くなる
    load_factor = 1.0 + ((concurrent_executions - 1) * 0.1)  # 10%ずつ増加
    
    # ただし最大でも2倍までに制限
    load_factor = min(2.0, load_factor)
    
    return estimated_seconds * load_factor

これらの推定方法をブレンドした「特製予測スープ」により、「あとどれくらい?」という永遠の疑問に対して、かなり正確な答えを提供できます。Amazon配送の「17:00-19:00に届きます」よりも、ずっと精度が高いかも...?(言い過ぎました、すみません)

6. ブラウザを閉じても継続処理が可能 — 魔法のURL

「あのぉ、1時間の動画を作りたいんですけど、その間ずっとブラウザを開いておかないといけないんですか?」というユーザーの悲痛な叫びに応えるのが、この機能です。答えはもちろん「いいえ」。

処理URLは、まるで「デジタル世界のブックマーク」のようなもの。動画が絶賛料理中でも、あなたはキッチン(ブラウザ)から出て、買い物(他の作業)に行くことができます。戻ってきたら、料理(動画)の出来具合をチェックできるというわけです。

このURLは、ユーザーがリクエストを送信した瞬間に生成され、処理画面に表示されます。ユーザーがこのリンクにアクセスすると、裏側では以下のような魔法が起きています:

  1. ページが読み込まれると、「ライフサイクルフック」 という特別な仕掛けが発動
  2. 「おーい、サーバー!この動画の状況はどうなった?」という自動APIリクエストが飛ぶ
  3. サーバーが「処理中だよ(45%完了)」「完成したよ!」「ごめん、失敗しちゃった…」などの状態を返す
  4. その返答に基づいて適切な画面が表示される
// フロントエンドの自動APIリクエスト(簡略化)
useEffect(() => {
  const checkStatus = async () => {
    const response = await fetch(`/api/videos/${jobId}/status`);
    const data = await response.json();
    
    if (data.status === 'completed') {
      // やったー!完成したぞ画面
      showCompletedScreen(data);
    } else if (data.status === 'processing') {
      // もう少し待ってね画面
      updateProgressBar(data.progress);
    }
  };
  
  // ページ読み込み時に実行
  checkStatus();
  
  // 処理中なら3秒ごとに更新
  const interval = setInterval(checkStatus, 3000);
  return () => clearInterval(interval);
}, [jobId]);

このURLをメールで自分に送ったり、LINEで友達に自慢したり(「僕の90分作業用BGM、まだ調理中なんだ~」)、ブックマークしたりできます。次の日に見たら、できたてホヤホヤの動画が待っているかもしれませんね!

<div class="text-center">
  <p class="text-sm text-gray-500 mb-4">
    処理中にブラウザを閉じても処理は続行されます。<br>
    以下のリンクを使用して後で結果を確認できます。
  </p>
  <div class="bg-gray-100 p-3 rounded-lg mb-6">
    <p class="select-all text-blue-600 break-all">
      https://example.com/videos/550e8400-e29b-41d4-a716-446655440000
    </p>
  </div>
</div>

番外編:ライフサイクルフックの裏側 — 「自動起動の秘密」

動画処理URLにアクセスすると、なぜ自動的にAPIリクエストが飛ぶのでしょうか?その裏側には「ライフサイクルフック」と呼ばれる仕組みが隠れています。中でも、Reactの「useEffect」という魔法の呪文が重要な役割を果たしています。

ライフサイクルフックって何?

ウェブページには「生まれる(表示される)」「変化する」「消える(閉じられる)」といった"人生の節目"があります。ライフサイクルフックは、これらの重要な瞬間に自動的に実行される特別な命令のことです。

useEffectの3つの使い方

1. 「ページが表示されたとき」に実行

// コンポーネントが画面に表示されたとき一度だけ実行
useEffect(() => {
  console.log('ページが表示されました!');
  // ここでAPIリクエストを送信
  fetchVideoStatus(jobId);
}, []); // 空の配列がポイント!

2. 「状態が変化したとき」に実行

// progress(進捗状況)が変わるたびに実行
useEffect(() => {
  console.log(`進捗状況が ${progress}% に更新されました`);
  // 進捗が100%になったら何か特別なことをする
  if (progress === 100) {
    showCompletionConfetti();
  }
}, [progress]); // progressという値を監視

3. 「ページから離れるとき」に実行

useEffect(() => {
  // 定期的な更新を設定
  const intervalId = setInterval(() => {
    checkVideoStatus(jobId);
  }, 3000);
  
  // ページから離れるときに実行される関数を返す
  return () => {
    console.log('さようなら!定期更新を停止します');
    clearInterval(intervalId);
  };
}, [jobId]);

実際の動画処理ページでの活用例

実際の動画処理ステータス確認ページでは、これらを組み合わせて使っています:

import React, { useState, useEffect } from 'react';

function VideoProcessingPage({ jobId }) {
  const [status, setStatus] = useState('loading');
  const [progress, setProgress] = useState(0);
  
  // ページ表示時に実行され、定期的なチェックを開始
  useEffect(() => {
    console.log('処理状況確認ページが開かれました');
    
    // 最初の状態確認
    checkStatus();
    
    // 3秒ごとに状態を確認する設定
    const intervalId = setInterval(() => {
      checkStatus();
    }, 3000);
    
    // ページから離れるときにインターバルをクリア
    return () => {
      clearInterval(intervalId);
    };
  }, [jobId]); // jobIdが変わったときも再実行
  
  // 状態確認用の関数
  const checkStatus = async () => {
    try {
      const response = await fetch(`/api/videos/${jobId}/status`);
      const data = await response.json();
      
      setStatus(data.status);
      setProgress(data.progress);
      
      // 処理完了していたら定期確認を停止
      if (data.status === 'completed') {
        clearInterval(intervalId);
      }
    } catch (error) {
      console.error('状態確認中にエラーが発生しました', error);
    }
  };
  
  // 以下、表示用のJSX...
}

このようにuseEffectを使うことで、「ページを開いたら自動的に状態を確認」「定期的に更新」「ページを閉じたらちゃんと片付け」といった一連の流れを作ることができます。

まるで、お客様が店に入ってきたら自動的に挨拶し、定期的に「何かお困りですか?」と声をかけ、お客様が帰るときには「またのお越しをお待ちしております」と見送る、優秀な店員さんのような振る舞いをプログラムできるのです!

7. 処理結果のパーマリンク提供 — 「宝物へのマップ大作戦」

完成した作品へのアクセス方法は「宝の地図」のように大切です。パーマリンク(永続的なURL)は、あなたの動画という宝物への地図です。このURLは魔法のポータルのように、いつでもあなたの作品世界に直接ワープできる機能を持っています:

  1. 動画のプレビュー再生:「おお、これが私の作品か!」とブラウザ上で即座に確認できる試写室機能
  2. ダウンロードボタン:「よーし、YouTubeにアップロードするぞ!」と意気込むあなたのための簡単MP4ダウンロード機能
  3. メタデータ表示:「この動画、どれくらいの容量だっけ?」という疑問に答える詳細情報ボード
  4. 使用素材の履歴:「あれ?どの曲を使ったんだっけ?」という物忘れにも対応する素材リスト

この「魔法のリンク」は30日間の命を持ち、その間はいつでも宝物庫に出入り可能。「あ、あの動画どこだっけ...」という心配から解放されます。まるで、デジタル世界の銀行金庫のようなものです!

// ResultHandler Lambda関数の実装例
const getResultPage = async (jobId) => {
  try {
    // DynamoDBからジョブ情報を取得
    const jobData = await getJobData(jobId);
    
    if (jobData.status === 'completed') {
      // 動画のダウンロードURL(プリサインド)の生成
      const video_url = s3_client.generate_presigned_url(
        'get_object',
        Params={
          'Bucket': OUTPUT_BUCKET,
          'Key': `outputs/${jobId}/bgm_video.mp4`
        },
        ExpiresIn=604800  // 1週間
      );
      
      // サムネイルのURL生成
      const thumbnail_url = s3_client.generate_presigned_url(
        'get_object',
        Params={
          'Bucket': OUTPUT_BUCKET,
          'Key': `outputs/${jobId}/thumbnail.jpg`
        },
        ExpiresIn=604800  // 1週間
      );
      
      // 結果ページに必要な情報を返す
      return {
        status: 'completed',
        videoUrl: video_url,
        thumbnailUrl: thumbnail_url,
        downloadUrl: video_url,
        duration: jobData.result.duration,
        size: jobData.result.size,
        resolution: jobData.configuration.resolution,
        usedFiles: {
          audioFiles: jobData.audioFiles,
          imageFile: jobData.imageFile
        },
        createdAt: jobData.createdAt
      };
    } else if (jobData.status === 'processing' || jobData.status === 'queued') {
      // まだ処理中の場合は処理状況を返す
      return {
        status: jobData.status,
        progress: jobData.progress,
        message: jobData.message || '処理中...',
        redirectToProcessing: true
      };
    } else {
      // 処理失敗時
      return {
        status: 'failed',
        message: jobData.message || '処理に失敗しました',
        canRetry: true
      };
    }
  } catch (error) {
    console.error('Error getting result page data:', error);
    return {
      status: 'error',
      message: '結果の取得中にエラーが発生しました'
    };
  }
};

まとめ — 「退屈を冒険に変える大作戦」

これらの7つの秘策により、「動画が作られるのを待つ」という本来退屈な時間が、「今どうなってるかな?」というちょっとした冒険に変わります。技術的には複雑な仕組みも、ユーザーにとっては「おお、すごい!便利!」と感じる魔法のような体験になるよう設計されています。

次回あなたが60分の作業用BGM動画を生成するとき、バックグラウンドではこんな工夫たちが黙々と働いていることを思い出してみてください。そして、プログレスバーの進む様子を眺めながら、「今、誰かのPC上で、私の動画のために何千行ものコードが実行されているんだな...」としみじみ感じてみてはいかがでしょうか。

待ち時間が楽しくなる魔法、それがユーザー体験デザインの真髄なのです!

Discussion