はじめに
AWS PollyとFFmpegを使って、1つの動画から10言語対応のHLS動画を自動生成するシステムを構築しました。
開発の背景
WebRTC技術を活用した動画ベースの情報共有サービス(画面に表示される質問に対して回答する様子を録画してチーム内で共有する「Slackの動画版」のようなサービス)を開発していました。
このサービスのグローバル展開を見据え、多国籍のチーム間での情報共有を実現する仕組みが必要でした。日本、アメリカ、ヨーロッパ、インドなど、異なる言語を話すメンバー間でも同じ動画コンテンツを共有できるよう、動画の多言語化が必須となりました。
実際に、インドのエンジニアマネジメントでこのシステムを活用していました。
重要なアプローチ:マルチオーディオトラック
各言語ごとに別々の動画ファイルを作るのではなく、1つの動画に複数の音声トラック(マルチオーディオトラック)を埋め込むというアプローチを採用しました。
これにより:
- ファイル管理が簡素化(10言語でも1ファイル)
- 視聴者が再生中に言語を切り替え可能(Netflixのような体験)
- 更新が容易
この記事では、この仕組みの実装詳細とハマったポイントを中心に解説します。
実現したこと
- 1つのMP4動画 + 質問と回答の配列 → 10言語対応HLS動画
- マルチオーディオトラック方式で1ファイルに統合
- 視聴者が再生中に言語を切り替え可能
- 完全自動化(API呼び出しのみ)
技術スタック
- 音声合成: AWS Polly (SSML)
- 動画処理: FFmpeg
- 配信形式: HLS (HTTP Live Streaming)
システムの全体像
動画の構造
このサービスの動画は以下の構造です:
- 画面: 「今週の進捗を教えてください」という質問テキストが表示
- 音声: ユーザーが「今週は新機能の実装を進めました」と回答
多言語化では、質問テキストは翻訳するだけ。音声合成が必要なのは回答部分のみです。
処理フロー
[ステップ1: 音声生成]
質問と回答の配列 → AWS Polly (SSML) → 各言語の音声MP3
↓
FFmpegで時間調整して結合
↓
各言語の完成した音声ファイル
[ステップ2: HLS動画生成]
元動画MP4 + 各言語音声MP3 → FFmpegでマルチオーディオトラックHLS化
↓
10言語切り替え可能な動画
ステップ1: 音声生成処理の実装
入力データ
質問と回答のペアの配列:
{
videoId: "video123",
language: "ja",
texts: [
{
question: "今週の進捗を教えてください",
answer: "今週は新機能の実装を進めました",
startTime: 0.0
},
{
question: "課題はありますか",
answer: "はい、テストケースの作成が遅れています",
startTime: 5.0
},
{
question: "次週の予定は",
answer: "リリース準備を進めます",
startTime: 10.0
}
],
duration: 15.5
}
各answerをAWS Pollyで音声合成します。
ポイント1: SSMLで音声の長さを動画の尺に合わせる
AWS Pollyのamazon:max-duration属性を使って、各音声の長さを制御します。
const createSsml = (texts, videoDuration) => {
return texts.map((v, index) => {
// 次のテキストまでの時間を計算
let duration = videoDuration - v.startTime;
if (texts[index + 1]) {
duration = texts[index + 1].startTime - v.startTime;
}
// SSMLで音声の長さを制御
const prosodyStart = `<prosody amazon:max-duration="${duration}s">`;
const prosodyEnd = `</prosody>`;
// 特殊文字の置換(&はPollyでエラーになる)
const text = v.text.replace(/&|&/g, ' and ');
return {
startTime: v.startTime,
duration,
text: `<speak>${prosodyStart}${text}${prosodyEnd}</speak>`
};
});
};
生成されるSSML例:
<speak>
<prosody amazon:max-duration="5.0s">
今週は新機能の実装を進めました
</prosody>
</speak>
Pollyが自動的に読み上げ速度を調整し、5.0秒以内に収めてくれます。
ポイント2: AWS Pollyで音声合成
const { polly } = require('@aws-sdk/client-polly');
const fs = require('fs');
const downloadPolly = async (voiceId, ssmlText, outputPath) => {
const file = fs.createWriteStream(outputPath);
const { AudioStream } = await polly.send(
new SynthesizeSpeechCommand({
Engine: 'standard', // or 'neural'
OutputFormat: 'mp3',
VoiceId: voiceId, // 'Takumi', 'Matthew', etc.
Text: ssmlText,
SampleRate: '24000',
TextType: 'ssml', // ← 重要
})
);
return new Promise((resolve, reject) => {
AudioStream.on('end', () => resolve())
.on('error', (error) => reject(error))
.pipe(file);
});
};
// 各セリフの音声を生成
await Promise.all(
ssml.map((v, index) => {
return downloadPolly('Takumi', v.text, `/tmp/audio-${index}.mp3`);
})
);
ポイント3: FFmpegで複数音声を時間調整して結合
ここが最も難しい部分です。複数の音声ファイルを、指定されたタイミングで配置して1つのファイルに結合します。
const concatVoice = (ssml, outputPath, videoDuration) => {
// 入力ファイル指定
const inputCommand = ssml
.map((v, index) => `-i /tmp/audio-${index}.mp3`)
.join(' ');
// 各音声の開始タイミング(ミリ秒)を計算
const delays = ssml.map((v, index) => {
let delay = ssml[0].startTime;
for (let i = 0; i < index; i++) {
delay += ssml[i].duration;
}
return delay * 1000; // ミリ秒に変換
});
// adelayフィルタで各音声を遅延
const delayCommand = delays
.map((v, index) => `[${index}]adelay=${v}|${v}[a${index}];`)
.join(' ');
// amixで音声をミックス
const amixCommand = delays
.map((v, index) => `[a${index}]`)
.join('');
// コマンド組み立て
const tmpPath = `/tmp/tmp.mp3`;
const command1 = `ffmpeg -y ${inputCommand} \
-filter_complex "${delayCommand} ${amixCommand}amix=inputs=${delays.length}:duration=longest[a]" \
-map "[a]" ${tmpPath}`;
// 動画の長さに合わせてパディング
const command2 = `ffmpeg -y -i ${tmpPath} \
-af "apad=whole_dur=${videoDuration}" ${outputPath}`;
return exec(`${command1} && ${command2}`);
};
実際に生成されるFFmpegコマンド例(3つの音声の場合):
# ステップ1: 各音声を遅延させてミックス
ffmpeg -y \
-i /tmp/audio-0.mp3 \
-i /tmp/audio-1.mp3 \
-i /tmp/audio-2.mp3 \
-filter_complex "[0]adelay=0|0[a0]; [1]adelay=3500|3500[a1]; [2]adelay=8000|8000[a2]; [a0][a1][a2]amix=inputs=3:duration=longest[a]" \
-map "[a]" /tmp/tmp.mp3
# ステップ2: 動画の長さに合わせて無音追加
ffmpeg -y -i /tmp/tmp.mp3 \
-af "apad=whole_dur=15.5" \
/tmp/output.mp3
フィルタの詳細:
-
[0]adelay=0|0[a0]: 1つ目の音声を0ms遅延(左右チャンネル) -
[1]adelay=3500|3500[a1]: 2つ目を3500ms遅延 -
[2]adelay=8000|8000[a2]: 3つ目を8000ms遅延 -
amix=inputs=3:duration=longest: 3つの音声をミックス -
apad=whole_dur=15.5: 15.5秒になるまで無音追加
ステップ2: HLS動画生成処理の実装
HLS形式とは
HLS (HTTP Live Streaming) はAppleが開発した動画配信プロトコルで、以下の特徴があります:
- 複数の音声トラックを持てる
- 視聴者が再生中に言語切り替え可能
-
.m3u8(プレイリスト) +.ts(セグメント) で構成
マルチオーディオトラックHLS生成のコード
const executeHls = (videoPath, audioFiles, outputDir) => {
// 音声ファイルを入力に追加
const inputCommand = audioFiles
.map((path) => `-i ${path}`)
.join(' ');
// 各音声トラックをAACエンコード
const mapCommand = audioFiles
.map((v, index) => `-map ${index + 1}:a -c:a aac`)
.join(' ');
// 各音声トラックに言語情報を付与
const streamMapCommand = audioFiles
.map((path, index) => {
const lang = path.match(/\/(\w+)\.mp3$/)[1]; // ファイル名から言語コード抽出
return `a:${index + 1},agroup:aud_low,language:${lang},name:${lang}`;
})
.join(' ');
const command = `mkdir -p ${outputDir} && ffmpeg \
-i ${videoPath} ${inputCommand} \
-map 0:a -c:a copy ${mapCommand} -map 0:v -c:v copy \
-f hls \
-hls_playlist_type vod \
-var_stream_map "a:0,agroup:aud_low,default:yes,language:default,name:default ${streamMapCommand} v:0,agroup:aud_low" \
-master_pl_name master.m3u8 \
${outputDir}/out_%v.m3u8`;
return exec(command);
};
// 使用例
await executeHls(
'/tmp/video.mp4',
['/tmp/ja.mp3', '/tmp/en.mp3', '/tmp/de.mp3'],
'/tmp/output'
);
実際のFFmpegコマンド(10言語の場合)
ffmpeg \
-i /tmp/video.mp4 \
-i /tmp/ja.mp3 \
-i /tmp/en.mp3 \
-i /tmp/de.mp3 \
-i /tmp/es.mp3 \
-i /tmp/fr.mp3 \
-i /tmp/it.mp3 \
-i /tmp/nl.mp3 \
-i /tmp/pt.mp3 \
-i /tmp/ru.mp3 \
-i /tmp/zh.mp3 \
-map 0:a -c:a copy \
-map 1:a -c:a aac \
-map 2:a -c:a aac \
-map 3:a -c:a aac \
-map 4:a -c:a aac \
-map 5:a -c:a aac \
-map 6:a -c:a aac \
-map 7:a -c:a aac \
-map 8:a -c:a aac \
-map 9:a -c:a aac \
-map 10:a -c:a aac \
-map 0:v -c:v copy \
-f hls \
-hls_playlist_type vod \
-var_stream_map "a:0,agroup:aud_low,default:yes,language:default,name:default a:1,agroup:aud_low,language:ja,name:ja a:2,agroup:aud_low,language:en,name:en a:3,agroup:aud_low,language:de,name:de a:4,agroup:aud_low,language:es,name:es a:5,agroup:aud_low,language:fr,name:fr a:6,agroup:aud_low,language:it,name:it a:7,agroup:aud_low,language:nl,name:nl a:8,agroup:aud_low,language:pt,name:pt a:9,agroup:aud_low,language:ru,name:ru a:10,agroup:aud_low,language:zh,name:zh v:0,agroup:aud_low" \
-master_pl_name master.m3u8 \
/tmp/output/out_%v.m3u8
重要なオプション解説:
| オプション | 説明 |
|---|---|
-map 0:a -c:a copy |
元動画の音声をそのままコピー |
-map 1:a -c:a aac |
1番目の入力音声をAACエンコード |
-map 0:v -c:v copy |
映像は再エンコードせずコピー(高速化) |
-f hls |
HLS形式で出力 |
-hls_playlist_type vod |
VOD用プレイリスト |
-var_stream_map |
各ストリームの言語情報定義 |
生成されるファイル構成
output/
├── master.m3u8 # マスタープレイリスト
├── out_0.m3u8 # オリジナル音声プレイリスト
├── out_1.m3u8 # 日本語プレイリスト
├── out_2.m3u8 # 英語プレイリスト
├── ...
├── out_11.m3u8 # 動画プレイリスト
├── out_00.ts, out_01.ts # オリジナル音声セグメント
├── out_10.ts, out_11.ts # 日本語音声セグメント
└── ...
master.m3u8の中身:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="default",DEFAULT=YES,LANGUAGE="default",URI="out_0.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="ja",LANGUAGE="ja",URI="out_1.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="en",LANGUAGE="en",URI="out_2.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO="aud_low"
out_11.m3u8
ハマったポイントと解決方法
1. FFmpegのフィルタグラフが難解
問題:
-
[0],[a0]などのラベリングが分かりにくい - 動的にコマンドを生成する必要がある
解決方法:
- まず2つの音声で手動コマンドを書いて理解
-
-loglevel debugでFFmpegの詳細ログを確認 - 中間ファイルを保存して、各ステップの結果を確認
# デバッグ用
ffmpeg -loglevel debug -i input.mp3 ... 2> debug.log
2. 音声のタイミングがズレる
問題:
-
adelayの計算ミス - 左右チャンネルで遅延が異なる
解決方法:
- ミリ秒単位で正確に計算
- ステレオの場合、両チャンネルに同じ遅延を適用:
adelay=3500|3500
3. AWS Pollyの3000文字制限
問題:
- 長いテキストは1リクエストで処理できない
解決方法:
- テキストを分割して複数リクエスト
- 各音声をFFmpegで結合(上記の
adelay処理)
4. 言語によって音声速度が異なる
問題:
- 英語は速く、日本語は遅い傾向
-
max-durationだけでは調整しきれない
解決方法:
- SSMLの
<prosody rate="slow">で速度調整 - 言語ごとに係数を設定
const rateAdjustments = {
ja: 1.0,
en: 0.9, // 少し遅く
zh: 1.1 // 少し速く
};
const prosodyStart = `<prosody amazon:max-duration="${duration}s" rate="${rateAdjustments[lang]}">`;
5. HLSセグメント長の調整
問題:
- デフォルトの6秒セグメントでは、短い動画で問題
解決方法:
-hls_time 2 # セグメント長を2秒に設定
6. メモリ不足エラー
問題:
- 長尺・高画質動画でFFmpegがクラッシュ
解決方法:
-max_muxing_queue_size 1024 # キューサイズ増加
-preset ultrafast # 処理速度優先
技術的な工夫ポイント
1. 動画の再エンコードを回避
映像トラックは -c:v copy でコピーのみ:
メリット:
- 処理時間: 数分 → 数十秒
- 画質劣化なし
- CPU/メモリ節約
2. 対応言語の定義
const voiceIds = {
ja: { name: 'Takumi', engine: 'standard' },
en: { name: 'Matthew', engine: 'standard' },
de: { name: 'Hans', engine: 'standard' },
es: { name: 'Enrique', engine: 'standard' },
fr: { name: 'Mathieu', engine: 'standard' },
it: { name: 'Giorgio', engine: 'standard' },
nl: { name: 'Ruben', engine: 'standard' },
pt: { name: 'Cristiano', engine: 'standard' },
ru: { name: 'Maxim', engine: 'standard' },
zh: { name: 'Zhiyu', engine: 'standard' },
};
3. エラーハンドリング
try {
await concatVoice(ssml, outputPath, videoDuration);
} catch (error) {
console.error('FFmpeg error:', error);
// 一時ファイルのクリーンアップ
await cleanupTempFiles();
throw error;
}
パフォーマンス
処理時間(動画: 15秒、10言語の場合)
- 音声生成: 約30秒(Polly API呼び出し)
- 音声結合: 約5秒(FFmpeg)
- HLS生成: 約10秒(FFmpeg)
- 合計: 約45秒
ボトルネック
- AWS Polly APIの呼び出し(並列実行で改善可能)
- ネットワーク転送(ファイルサイズに依存)
まとめ
AWS PollyとFFmpegを組み合わせることで、動画の多言語化を自動化できました。
重要なポイント:
-
SSMLの
amazon:max-durationで音声の長さを制御 - FFmpegのadelayフィルタで音声タイミングを精密配置
- HLSのvar_stream_mapでマルチオーディオトラック実現
- -c:v copyで動画を再エンコードせず高速処理
実用的な価値:
- 人手での多言語化と比較して圧倒的な効率化
- 視聴者にとっても便利(再生中に言語切り替え可能)