メインコンテンツへスキップ
友田 陽大
生成AI・LLM・RAG
Python
FastAPI
Celery
GPU
AI動画
リップシンク
MLOps
アーキテクチャ設計
Azure
Terraform

本番品質のAI動画ローカライズ基盤:長尺GPUパイプラインを『落とさず・安く・自然に』完走させる設計

動画をアップロードするだけで音声分離→文字起こし→翻訳→多言語吹き替え→口元同期まで全自動化するGPU推論パイプラインを、本番運用に耐える品質まで引き上げた設計の全記録。スポット中断からの再開、発話区間検出によるGPUコスト約40%削減、等時性制御、拡散モデルのOOM・幻覚ハードニングまで実装レベルで解説します。

公開日
読了時間
19分
著者
友田 陽大
シェア

導入:「モデルを繋ぐだけ」では、動画ローカライズは本番運用できない

「Whisperで文字起こしして、LLMで翻訳して、TTSで吹き替えて、リップシンクモデルで口を合わせる」———この一文は、動画ローカライズの全自動化を5分でデモするためのレシピとしては正しい。しかし、これをそのまま実装したものは、顧客の前で必ず壊れます

なぜか。動画ローカライズは、「重く・高価で・確率的に失敗するGPU処理」が直列に5段つながった、典型的な不安定パイプラインだからです。デモ動画(10秒・正面・無音なし)では露呈しない問題が、現実の素材(30分・複数話者・長い無音・カット切り替え・横顔)で一斉に噴出します。

本稿は、私が単独で設計・実装した実プロダクト「AI動画ローカライズ・リップシンク基盤」を題材に、この不安定パイプラインを本番運用に耐える品質へ引き上げた設計判断を、実装レベルで開示するものです。扱うテーマは次の3点に集約されます。

  • 落とさない(Reliability): 数十分のGPUジョブを、スポットインスタンスの強制停止をまたいで完走させる。
  • 安く(Cost): GPU推論という最も高価なリソースを、品質を落とさずに削る。
  • 自然に(Quality): 機械翻訳の尺差と拡散モデルの幻覚を抑え、口元と音声を破綻なく合わせる。

関連する実績の概要は AI動画ローカライズ・リップシンク基盤 にまとめています。本稿はその「なぜそう作ったか」を掘り下げる技術編です。


全体像:5段パイプラインと、それを支える「差し替え可能」な骨格

まず処理の流れを確定させます。1本の動画は、次の5段を順に通過します。

アップロード
  └─ ① 音声分離  (ボーカル / BGM を分離)
       └─ ② 文字起こし (STT・言語自動判定・タイムスタンプ付き字幕)
            └─ ③ 翻訳 (字幕単位・発話尺を制約に与える)
                 └─ ④ 音声合成 (声質クローン・等時性フィット)
                      └─ ⑤ リップシンク (口元同期・字幕焼き込み)
                           └─ 完成動画

各段の成功時はステータスを EDITING(ユーザーレビュー可能)へ遷移させ、人手で字幕・訳文を修正できる「人間参加(human-in-the-loop)」を挟みます。いずれかの段で失敗したら FAILED とし、どの段で・何が失敗したかを error_stage / error_message に記録します。これが後述の再開・デバッグの土台になります。

設計原則:AIエンジンは「実装詳細」であって「アーキテクチャ」ではない

このドメインで最も重要な意思決定は、「どのモデルを使うか」を構造から追い出すことでした。

リップシンクのSOTAは数か月単位で入れ替わります。実際、このプロダクトでも Wav2Lip 系 → MuseTalk → LatentSync と主役が移り、翻訳も NLLB から vLLM(Qwen3) へ替わりました。特定モデルにコードが密結合していたら、モデルが更新されるたびにアーキテクチャが崩壊します。

そこで全段を「インターフェース → プロバイダ → ファクトリ」の3点セットで構成しました。各段は抽象基底クラス(ABC)で契約を定義し、具体実装(プロバイダ)は環境変数で選択、生成はファクトリに集約します。

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class TranscriptionResult:
    segments: list["Segment"]          # (start, end, text) のタイムスタンプ付き
    detected_language: str
    language_probability: float


class Transcriber(ABC):
    """文字起こしの契約。実装はこの背後に隠れる。"""

    @abstractmethod
    async def transcribe(
        self, audio_path: str, lang: str | None = None
    ) -> TranscriptionResult: ...

ファクトリの肝は2つ。環境変数駆動の選択と、遅延インポートです。

import importlib
from app.core.config import settings
from app.core.enums import STTProvider

# enum値 → (モジュールパス, クラス名)。重いMLライブラリはここでは import しない。
_STT_PROVIDERS: dict[STTProvider, tuple[str, str]] = {
    STTProvider.FASTER_WHISPER: ("app.services.processors.transcriber.faster_whisper", "FasterWhisperTranscriber"),
    STTProvider.REAZON_SPEECH:  ("app.services.processors.transcriber.reazon_speech",  "ReazonSpeechTranscriber"),
    STTProvider.OPENAI_WHISPER: ("app.services.processors.transcriber.openai_whisper", "OpenAIWhisperTranscriber"),
}


def create_transcriber() -> Transcriber:
    module_path, class_name = _STT_PROVIDERS[settings.stt_provider]
    module = importlib.import_module(module_path)   # 選ばれた実装だけを遅延ロード
    return getattr(module, class_name)()

なぜ遅延インポートが死活的か。 faster-whispervLLM、拡散モデル系のライブラリはトップレベルで import するだけで CUDA コンテキストを掴み、数百MB〜数GBのVRAMを確保します。ファクトリの外で import していたら、TTSしか使わないワーカープロセスがリップシンク用ライブラリのVRAMまで巻き込んで枯渇します。import 文の位置が、そのまま本番のOOM要因になる———これはGPUアプリ特有の、見落とされがちな落とし穴です。

この骨格により、音声分離・文字起こし・翻訳・音声合成・リップシンク・ストレージの計6層が、コード変更ゼロ・環境変数だけで差し替え可能になりました。

インターフェース主な実装選択キー
音声分離AudioSeparatorFFmpeg / Demucs / UVR5(MDX-Net)SEPARATOR_PROVIDER
文字起こしTranscriberfaster-whisper(large-v3) / ReazonSpeech / OpenAI WhisperSTT_PROVIDER
翻訳TranslatorvLLM(Qwen3-8B-AWQ) / 4bit量子化Llama-3TRANSLATOR_PROVIDER
音声合成Synthesizer声質クローン対応TTS(HTTPサービス)TTS_PROVIDER
リップシンクLipSyncerMuseTalk / LatentSync / VideoReTalkingLIPSYNC_PROVIDER
ストレージStorageBackendローカルFS / Azure BlobSTORAGE_TYPE

これは Open-Closed 原則の実地適用です。新エンジンの追加は「新しいプロバイダを1つ書いて registry に1行足す」だけ。既存コードには一切触れません。


第1の壁:長尺GPUジョブを「落とさない」

なぜHTTPリクエストの中で処理してはいけないか

30分の動画のリップシンクは、T4 1枚で1時間以上かかることがあります。これをAPIのリクエスト・レスポンス内で同期実行するのは論外です。タイムアウト、リトライによる二重実行、進捗の不可視———すべてが破綻します。

解は明快で、重い処理は全て Celery ワーカーへ非同期退避します。ただしGPUアプリ特有の制約が1つ。ワーカーは --pool=threads --concurrency=1 で動かします。

celery -A app.worker.celery_app worker --pool=threads --concurrency=1 -Q gpu -n gpu@%h

並列度を1に固定する理由は、GPUを直列化するためです。1枚のT4に2つの拡散モデル推論を同時に流せばVRAMが即枯渇します。プロセスをforkするデフォルトの prefork プールは、CUDAコンテキストやvLLMのデーモンプロセスと相性が悪く、無言で固まります。「速くするための並列化」が「確実に壊すための並列化」になるのがGPUワークロードの直感に反する点です。スループットはセグメント内の工夫で稼ぎ、タスク間は直列に保つ———この割り切りが安定性を生みます。

スポット中断に耐える:セグメント分割 + 永続キャッシュ + 再開

コストのため、本番GPUはスポットVM(T4 / Standard_NC8as_T4_v3)を使います。スポットは安い代わりに、クラウド側の都合で予告なく強制停止されます。1時間のリップシンクが59分で吹き飛んだら、素朴な実装なら最初からやり直しです。これは事業として成立しません。

そこで長尺動画をセグメントに分割し、各セグメントの出力を永続ディスクにキャッシュします。永続ディスクは Terraform で prevent_destroy を付け、VMが再作成されても生き残るよう保護しています。

キャッシュキーの設計が再開の正しさを決めます。入力とパラメータが同じなら、出力は決定的に同じ———この冪等性を保証するため、キーは次の要素から導出します。

import hashlib


def segment_cache_key(
    project_id: str,
    audio_id: str,
    segment_seconds: float,
    engine: str,                 # "musetalk" / "latentsync" ...
    tuning: str,                 # inference_steps, guidance_scale 等を畳んだ署名
    sync_spans: tuple[tuple[float, float], ...],  # 発話区間の確定計画
) -> str:
    raw = f"{project_id}|{audio_id}|{segment_seconds}|{engine}|{tuning}|{sync_spans}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]

再開ロジックは「キャッシュにあれば計算しない」という単純な規律に落ちます。

async def lipsync_segment(seg: Segment, ctx: LipSyncContext) -> Path:
    key = segment_cache_key(ctx.project_id, ctx.audio_id, ctx.segment_seconds,
                            ctx.engine, ctx.tuning, ctx.sync_spans)
    cached = ctx.cache_dir / f"{key}_{seg.index}.mp4"
    if cached.exists():
        return cached                      # スポット中断後の再開はここで効く(冪等)
    out = await ctx.engine.sync(seg)       # 重いGPU処理は未計算分だけ
    out.replace(cached)                    # アトミックに確定
    return cached

スポットに殺されても、再起動後は最後に完了したセグメントの次から走り出します。enginetuning をキーに含めるため、エンジンやチューニングを変えれば自動的に別キャッシュとなり、古い結果の誤再利用も起きません。これが**再開可能(resumable)かつ冪等(idempotent)**なパイプラインの実体です。

段階別リトライと「精密に失敗する」例外設計

一過性の失敗(ネットワーク断、瞬間的なメモリ逼迫)はリトライで回復し、恒久的な失敗(不正な入力、ライセンス外モデル)は即座に止める。この区別のため、例外を15種の階層で設計しました。

class InfiniteBridgeError(Exception): ...

class ProcessingError(InfiniteBridgeError):
    def __init__(self, stage: str, message: str) -> None:
        self.stage = stage          # FAILED時に error_stage へ記録
        super().__init__(message)

class TranscriptionError(ProcessingError): ...
class TranslationError(ProcessingError): ...
class TTSGenerationError(ProcessingError):
    def __init__(self, segment_index: int, message: str) -> None:
        self.segment_index = segment_index   # どのセグメントが落ちたか
        super().__init__("synthesize", message)
class LipSyncError(ProcessingError): ...
class GpuServiceError(InfiniteBridgeError): ...      # 外部GPUサービスの異常
class TaskCancelledError(InfiniteBridgeError): ...   # ユーザーキャンセル(失敗ではない)
class PathTraversalError(InfiniteBridgeError): ...   # ストレージ境界の防御

リトライは段階ごとに回数を最適化し、HTTPで叩く外部GPUサービス(TTS・リップシンク)には tenacity ベースの指数バックオフを共通化しました。重要なのはキャンセルを「失敗」と区別すること。TaskCancelledErrorFAILED に落とさず、タスクを静かに登録解除します。キャンセルフラグは Redis に持ち、各段の自然なチェックポイントで参照する協調的キャンセルです。

# 失敗ではないキャンセルを、失敗パスに巻き込まない
if await is_cancelled(project_id):
    raise TaskCancelledError(project_id)

第2の壁:GPUコストを「安く」する———無音を、賢く捨てる

観察:動画の口元は、大半の時間「動いていない」

GPU推論は本プロダクトで最も高価なリソースです。そして動画を観察すると、フレームの大半は実は発話していない———無言の間、BGMだけのカット、聞き手の表情。にもかかわらず素朴な実装は、全フレームを拡散モデルに通します。これは経済的に破綻しているうえ、品質まで悪化させます。拡散モデル系リップシンクは、無音区間や顔のないフレームで口元を「幻覚」させる既知の問題を持つからです。

つまり無音をGPUに通さないことは、コスト削減と品質向上を同時に達成する、一石二鳥の最適化でした。

発話区間の合成:字幕スロット ∪ 音声エネルギー

「実際に発話している区間」を、2つの情報源から合成します。**翻訳字幕のスロット(人間が確認済みの発話タイミング)**と、**吹き替え音声のエネルギースパン(実際に音が鳴っている区間)**の和集合です。前後に少しのマージン(口の開閉のための助走)を付け、近接する区間(短い無音)は結合して、GPUを起動する「同期ウィンドウ」を確定します。

def plan_sync_windows(
    subtitle_slots: list[Span],
    audio_energy_spans: list[Span],
    margin: float = 0.4,       # 口の開閉のための前後余白(秒)
    merge_gap: float = 2.0,    # この秒数未満の無音は同一ウィンドウへ結合
) -> list[Span]:
    spans = sorted(
        Span(s.start - margin, s.end + margin)
        for s in (*subtitle_slots, *audio_energy_spans)
    )
    merged: list[Span] = []
    for s in spans:
        if merged and s.start - merged[-1].end < merge_gap:
            merged[-1] = Span(merged[-1].start, max(merged[-1].end, s.end))
        else:
            merged.append(s)
    return merged   # この区間だけGPUへ。残りは原映像を温存。

確定したウィンドウの外側は、原映像のフレームをそのまま使うため、口元は元のまま自然で、GPUは1フレームも消費しません。この発話区間検出により、リップシンクのGPU処理コストを約40%削減しつつ、無音時の口元の破綻も同時に解消しました。

設計上の本質は「最適化=処理を速くする」ではなく「そもそも処理しない区間を見つける」ことにあります。最速のGPU処理は、実行しないGPU処理です。

構造的なコスト最適化:スポット + スマート自動停止

アルゴリズム的な削減に加え、インフラ側でも構造的にコストを抑えます。スポットVMでオンデマンド比を大きく圧縮し、タスク状態を見て安全に落とすスマート自動停止を実装しました。単純な時刻トリガーではなく、Celeryのアクティブタスク・キュー・ユーザーセッションを確認してから停止するため、処理中の動画を巻き込んで殺す事故を防ぎつつ、アイドル時のGPU課金をゼロに寄せます。Terraform の DevTest 自動停止を最終バックストップに重ね、二重の安全網にしています。


第3の壁:吹き替えを「自然に」———等時性という、地味で本質的な問題

翻訳は「意味」を訳すが、「尺」は訳してくれない

機械翻訳をそのまま吹き替えると、必ずこの問題に当たります。原語と訳語で、同じ意味の発話にかかる時間が違う。 英語の3秒の台詞が日本語では5秒に、あるいはその逆になる。これを無視して合成音声を字幕スロットに流し込むと、早口で潰れるか、間延びして次の台詞に食い込みます。口元同期以前に、音声そのものがスロットに収まらないのです。

この「発話を割り当て時間に収める」制約を**等時性(isochrony)**と呼びます。本プロダクトでは、4つの手段を優先順位付きで組み合わせて尺差を吸収します。

  1. 後続の無音ギャップを借用する: スロット直後に無音があれば、そこへ少しはみ出す。ただし 0.15秒 のブレス余白は必ず残す(息継ぎの自然さ)。
  2. 話速を上げる(上限1.2倍): 借用で足りなければ、知覚品質を損なわない範囲(最大20%)で速める。
  3. 時間伸縮する(Rubberband, 上限1.1倍): 逆に短すぎる音声は、ピッチを保ったまま最大10%引き延ばす。
  4. それでも合わなければ品質ゲートで弾く: セグメント単位のTTS失敗率が 20% を超えたら処理を中断し、無音挿入でグレースフルに退化させる。
def fit_to_slot(audio: AudioSegment, slot: Span, next_gap: float) -> AudioSegment:
    overflow = audio.duration - slot.duration
    if overflow <= 0:
        return rubberband_stretch(audio, ratio=min(slot.duration / audio.duration,
                                                    MAX_STRETCH))   # 1.1倍上限
    borrowable = max(0.0, next_gap - BORROW_GUARD)                  # 0.15秒は残す
    overflow -= min(overflow, borrowable)                          # まずギャップ借用
    if overflow > 0:
        speedup = min((audio.duration) / (audio.duration - overflow), MAX_SPEEDUP)
        audio = time_compress(audio, speedup)                      # 次に話速(1.2倍上限)
    return audio

非自明なのは、話速の上限(1.2倍)と伸縮の上限(1.1倍)が非対称である点です。人間の知覚は「速すぎる音声」より「遅すぎる音声」に敏感で、引き延ばしのアーティファクトが目立ちやすい。だから縮める側に余地を多く取り、伸ばす側を厳しく絞る。これは音響心理学の知見をパラメータに落とした結果で、数字の根拠を説明できることが本番品質の証だと考えています。


第4の壁:拡散モデルを、本番でハードニングする

最高品質のリップシンクは拡散モデル(LatentSync v1.5)で得られますが、拡散モデルは研究用コードのまま本番に置くと最も壊れやすいコンポーネントでもあります。3つのハードニングを施しました。

(1) フレームレート正規化と16フレーム単位の整列

LatentSync は内部で 25fps を前提とし、UNet が16フレームのチャンク単位で動きます。入力動画は 24/30/60fps と様々なので、入力を25fpsへ正規化し、セグメントを16フレームの倍数に整列させてから処理し、出力を元のfpsへ戻します。これを怠ると、チャンク境界でフレームが切り捨てられ、口元が一瞬ずれる「リップドリフト」が発生します。

(2) OOMを「設計で」回避する:30秒窓という物理上限

拡散パイプラインは推論ウィンドウ全体をホストRAMにデコードします。720pでは60秒窓でも22GBのRAM上限を超えてOOMキルされました。ここで「メモリを増やす」のは敗北です。スポットVMのRAMは有限で、増やせばコストに跳ね返る。代わりに、セグメント窓を30秒に固定して、ピークRAMが上限を超えない物理的保証を設計に組み込みました。MuseTalk(より軽量な潜在拡散)は120秒窓で十分なので、エンジンごとに窓サイズを変えています。

エンジン方式セグメント窓fps正規化位置づけ
MuseTalk潜在拡散(高速)120秒不要速度優先・標準品質
LatentSync v1.5拡散(高品質)30秒25fpsへ品質優先・口元の精度が高い
VideoReTalkingGAN系ライセンス検証中につき退役

エンジンとチューニングは、利用者には4つのプリセットとして抽象化して提示します。内部では「エンジン+推論ステップ数+ガイダンス強度」の束に解決されます。

プリセットエンジン推論ステップガイダンス体感
FASTMuseTalk(256)最速・標準
BALANCEDMuseTalk(384, 調整)速度と品質の中庸
HIGH_QUALITYLatentSync 1.5252.0高品質
ULTRALatentSync 1.5302.0最高品質

(3) 二分探索で「顔なしフレーム」だけを隔離する

最大の難所がこれです。拡散モデルは、ウィンドウ内に1枚でも顔が映らないフレーム(スライド切り替え、ロゴ画面、カット)があると、ウィンドウ全体の推論をハードに失敗させます。従来の素朴な実装は、このとき30秒の窓まるごとを「吹き替えのみ(口元同期なし)」にフォールバックしていました。顔なしフレーム1枚のために、29秒の良質な同期を捨てるわけです。

そこで、同期に失敗したウィンドウを二分探索で分割し、失敗する側を再帰的に絞り込んで、3秒 の下限まで顔なしフレームを隔離します。

def sync_with_bisect(window: Span, ctx: LipSyncContext) -> list[Result]:
    try:
        return [ctx.engine.sync(window)]                 # まず丸ごと試す
    except LipSyncError:
        if window.duration <= BISECT_MIN:                # 3秒下限
            return [dub_only(window)]                    # ここだけ吹き替えで救済
        mid = window.start + window.duration / 2
        return [
            *sync_with_bisect(Span(window.start, mid), ctx),
            *sync_with_bisect(Span(mid, window.end), ctx),  # 失敗源を半分に切り詰める
        ]

結果、フォールバックの影響範囲が「窓全体」から「実際に顔がない最小区間」へ縮小しました。これは可用性のための古典的なパターン(フォールバックは局所化する)を、拡散モデルの失敗特性に合わせて適用したものです。


全段を貫く規律:型安全と100%カバレッジ

ここまでの工夫は、回帰したら一瞬で価値を失います。スポット再開のキャッシュキーが1要素ズレれば誤再利用が起き、等時性の上限値が1つ変われば吹き替えが破綻する。だから本プロダクトは品質ゲートを妥協なく敷きました。

  • バックエンドはテストカバレッジ100%を必須化。CIで未達はビルド失敗。外部I/O(GPUサービス、ストレージ、DB)は全てモックし、ロジックを高速・決定的に検証します。
  • mypy strict・Ruff・Vulture で型・静的解析・デッドコードをゼロエラーに維持。any 型と print() は構造的に禁止。
  • フロントは境界でZod検証。Next.js 16 / React 19(Compiler有効)/ Mantine / TanStack Query を採用し、APIレスポンスは Zod スキーマを単一の真実源として、サーバー状態(TanStack Query)とUI状態(Zustand)を分離。any を持ち込まず、ESLint と Knip でデッドコードを排除します。

100%カバレッジは「数字のための数字」ではありません。外部依存を全てモックできる構造になっている=依存が正しく抽象化されていることの証明であり、前述のプラグイン型アーキテクチャと表裏一体です。テスト容易性は、良い設計の結果として現れます。


まとめ:本番品質とは、「壊れ方」を設計しきること

AI動画ローカライズの全自動化は、モデルを繋ぐ部分が全体の2割です。残りの8割———本稿で扱った「落とさない・安く・自然に・壊れ方を局所化する」———こそが、デモと本番を分ける境界線でした。要点を再掲します。

  1. AIエンジンは実装詳細に追い出す。インターフェース→プロバイダ→ファクトリ+遅延ロードで、モデル更新の影響を局所化する。
  2. スポット中断を前提に設計する。セグメント分割+冪等なキャッシュキーで、再開可能・冪等なパイプラインにする。
  3. 最速の処理は、しない処理。発話区間検出で無音をGPUに通さず、コストと品質を同時に改善する(約40%削減)。
  4. 等時性を数字で制御する。ギャップ借用・話速上限・時間伸縮を、知覚品質の根拠とともにパラメータ化する。
  5. 拡散モデルは本番でハードニングする。fps正規化・OOM回避の窓設計・二分探索によるフォールバック局所化。
  6. 品質ゲートを妥協しない。型安全と100%カバレッジが、上記すべての回帰を防ぐ。

これらはどれも派手ではありません。しかし、派手でない部分を設計しきれるかどうかが、AIプロダクトを「デモ」から「事業」へ引き上げます。私が単独で要件定義からGPUインフラ・本番運用まで担い、本案件の評価でクラウドワークスのエンジニア部門・総合 週間契約ランキング1位を頂けたのは、この地味さに価値を置いた結果だと考えています。

AI動画・GPUパイプライン・拡散モデルの本番化に課題をお持ちでしたら、設計からインフラまで一気通貫でご相談に乗れます。具体的な実績は下のリンクからご覧ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

AI動画ローカライズ・リップシンク基盤

ケーススタディを見る