メインコンテンツへスキップ

Go × FFmpegで動画連結を実装して踏んだ地雷まとめ|concat仕様・音ズレ・デッドロックの回避策

目次

Go言語のバックエンドアプリケーションで「動画を連結する機能」を実装する場合、単純に os/exec パッケージを使ってシェル経由で FFmpeg を呼び出すだけでは、プロダクション環境に耐えうる堅牢なシステムは構築できません。

動画処理特有の膨大なシステムログによるパイプのバッファ詰まり(デッドロック)、スマホで撮影された動画の可変フレームレート(VFR)に起因する深刻な音ズレ、並列実行時のCPU・メモリ枯渇など、実務では多くの地雷が待ち受けています。

この記事では、Go言語と FFmpeg を組み合わせて安全かつ効率的に動画を連結するための設計指針と、具体的なコード実装パターンを網羅して解説します。


1. FFmpegで動画を連結する3つの「concat」方式と選定基準
#

FFmpegで複数の動画ファイルを連結する方法には、主に3つのアプローチがあります。それぞれの仕組みと特性を正しく理解し、要件に応じて使い分けることが重要です。

方式の比較
#

方式特徴メリットデメリット主な用途
Concat Demuxerリストファイルを読み込む再エンコードなし(爆速)全動画の解像度、フレームレート、コーデックが完全一致必須同一デバイスで分割撮影された動画の連結
Concat Protocolバイナリレベルで直接結合実装が極めて単純TS(MPEG-TS)形式以外ではほぼ確実にファイルが破損するHLS/MPEG-DASH配信の分割TSセグメントの結合
Filter Complexストリームをデコードして再構成解像度やコーデックが異なっても連結可能再エンコード必須(高負荷・低速)ユーザー投稿動画など、出所の異なる動画の編集・連結

方式別の具体的なコマンド例
#

Concat Demuxer(同一フォーマット向け)
#

連結したいファイルのパスを記述したテキストファイル(input.txt)を用意して読み込ませます。

# input.txt
file 'input1.mp4'
file 'input2.mp4'
ffmpeg -f concat -safe 0 -i input.txt -c copy output.mp4

Filter Complex(異なるフォーマット向け)
#

動画と音声のストリームをデコードし、concat フィルタを用いて再結合した上でエンコードします。

ffmpeg -i input1.mp4 -i input2.mp4 -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" -c:v libx264 -c:a aac output.mp4

2. Goによる ffprobe を用いた動画メタ情報の解析
#

実務では、「可能な限り処理が高速でCPU負荷の低い Concat Demuxer を使用し、解像度やコーデックが異なる場合のみ Filter Complex による再エンコード処理にフォールバックする」という自動分岐設計が必要になります。

この分岐を判断するために、事前に対象動画の解像度、コーデック、アスペクト比、ピクセルフォーマットなどのメタ情報を ffprobe を用いて取得します。

Goによる ffprobe 実行と JSON 解析の実装
#

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os/exec"
	"time"
)

// FFProbeResult は ffprobe の JSON 出力をマッピングする構造体
type FFProbeResult struct {
	Streams []struct {
		CodecName  string `json:"codec_name"`
		Width      int    `json:"width"`
		Height     int    `json:"height"`
		PixFmt     string `json:"pix_fmt"`
		RFrameRate string `json:"r_frame_rate"`
	} `json:"streams"`
}

// GetVideoMetadata は指定された動画のメタ情報を取得します
func GetVideoMetadata(ctx context.Context, filePath string) (*FFProbeResult, error) {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, "ffprobe",
		"-v", "error",
		"-show_streams",
		"-select_streams", "v:0", // ビデオストリームのみを選択
		"-print_format", "json",
		filePath,
	)

	output, err := cmd.Output()
	if err != nil {
		return nil, fmt.Errorf("failed to execute ffprobe: %w", err)
	}

	var result FFProbeResult
	if err := json.Unmarshal(output, &result); err != nil {
		return nil, fmt.Errorf("failed to parse json output: %w", err)
	}

	if len(result.Streams) == 0 {
		return nil, fmt.Errorf("no video stream found in %s", filePath)
	}

	return &result, nil
}

この関数を用いて各動画のパラメータ(Width, Height, CodecName, PixFmt)を比較し、一致していれば Demuxer を採用し、異なっていれば Filter Complex を組み立てるロジックを構成します。


3. Go言語の os/exec におけるデッドロック問題とその回避
#

Goから exec.Command で FFmpeg や別の外部プロセスを実行する場合、最も陥りがちな重大なバグが 「標準エラー出力(stderr)のバッファ溢れによるデッドロック」 です。

デッドロックが発生する原因
#

FFmpeg は進捗状況やエンコード情報を標準エラー出力(stderr)にリアルタイムかつ大量に吐き出します。 cmd.CombinedOutput()cmd.Output() を使って標準エラー出力を Go 側で明示的に読み出さずに放置、あるいは同期処理で単純なバッファに溜め込んだ場合、OSのバッファ制限(多くの環境で 64KB)を超えた時点で FFmpeg プロセスが一時停止(ブロック)し、Go アプリケーション側の Wait() も永久に終了しなくなります。

解決策:非同期ストリーミングによるログ回収
#

この問題を回避するには、cmd.StderrPipe() を使用してストリームを取得し、別ゴルーチンで非同期にログを読み出し続ける必要があります。

デッドロックを回避する安全な FFmpeg 実行の実装
#

package main

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"log"
	"os/exec"
	"sync"
	"time"
)

// ExecuteFFmpegSafe はデッドロックを防止しつつ FFmpeg を実行します
func ExecuteFFmpegSafe(ctx context.Context, args []string) error {
	// プロセスのタイムアウト(例: 5分)をコンテキストに設定
	ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()

	cmd := exec.CommandContext(ctx, "ffmpeg", args...)

	// 標準エラー出力のパイプを取得
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		return fmt.Errorf("failed to create stderr pipe: %w", err)
	}

	if err := cmd.Start(); err != nil {
		return fmt.Errorf("failed to start ffmpeg: %w", err)
	}

	var wg sync.WaitGroup
	wg.Add(1)

	// 非同期でパイプからログを継続的に読み出す
	go func() {
		defer wg.Done()
		scanner := bufio.NewScanner(stderrPipe)
		
		// scanner.Scan() を呼び出し続けることでバッファが常に消費され、ブロックを防ぐ
		for scanner.Scan() {
			text := scanner.Text()
			// 実務では特定のエラーキーワード検知や、進捗パーセンテージの解析に使用可能
			log.Printf("[FFmpeg Log]: %s", text)
		}
		
		if err := scanner.Err(); err != nil && err != io.EOF {
			log.Printf("error reading ffmpeg stderr: %v", err)
		}
	}()

	// プロセスの終了を待機
	if err := cmd.Wait(); err != nil {
		return fmt.Errorf("ffmpeg process exited with error: %w", err)
	}

	// ログ読み出しゴルーチンの完了を確実に待機
	wg.Wait()
	return nil
}

4. 可変フレームレート(VFR)動画による「音ズレ」のメカニズムと解決策
#

スマートフォンで撮影された動画や、画面キャプチャツールで収録された動画の多くは、フレームレートが状況に応じて変化する VFR (Variable Frame Rate) になっています。

VFR動画を再エンコードなし(-c copy)で連結すると、動画のタイムスタンプ(PTS: Presentation Time Stamp)と音声ストリームがずれ、動画が進むにつれて音声が遅れて再生される、いわゆる 「音ズレ」 が発生します。

CFR(固定フレームレート)への正規化
#

この音ズレを回避するために、再エンコードを行う場合は必ずフレームレートを明示的に固定(CFR: Constant Frame Rate)に再設定します。また、オーディオのサンプリングレートを統一するために、連結フィルタ内で適切なオーディオ再サンプリングを指示します。

# 30fpsの固定フレームレート(CFR)へ強制指定し、動画同期を確保するコマンド
ffmpeg -i input.mp4 -r 30 -vsync cfr output.mp4

Goでの Filter Complex コマンド引数の組み立て例
#

異なるフレームレートや音声パラメータを持つ2つの動画を、音ズレなく連結するための Go のコマンド組み立てロジックです。

func BuildConcatArgs(input1, input2, output string) []string {
	return []string{
		"-y", // 出力ファイルを無条件に上書き
		"-i", input1,
		"-i", input2,
		"-filter_complex",
		// 各動画ストリームのタイムスタンプを再初期化(setpts/asetpts)し、
		// 音声は再サンプリング(aresample)をかけて同期ズレを完全に防ぐ
		"[0:v]setpts=PTS-STARTPTS,fps=30[v0];" +
		"[0:a]asetpts=PTS-STARTPTS,aresample=44100[a0];" +
		"[1:v]setpts=PTS-STARTPTS,fps=30[v1];" +
		"[1:a]asetpts=PTS-STARTPTS,aresample=44100[a1];" +
		"[v0][a0][v1][a1]concat=n=2:v=1:a=1[outv][outa]",
		"-map", "[outv]",
		"-map", "[outa]",
		"-c:v", "libx264",
		"-pix_fmt", "yuv420p", // 互換性の高いピクセルフォーマットに統一
		"-c:a", "aac",
		"-b:a", "128k",
		"-movflags", "+faststart", // Webブラウザでプログレッシブ再生可能にする
		output,
	}
}

5. バックエンド運用におけるリソース制限と安定化策
#

動画のエンコード処理はCPUとメモリを極めて激しく消費します。複数のユーザーリクエストを並列でそのまま受けて FFmpeg プロセスを乱立させると、サーバーのCPU使用率が100%に張り付き、応答不可や OOM (Out Of Memory) による強制ダウンが発生します。

実務では、Goのチャネルを用いた セマフォパターン による並列実行数の制御が不可欠です。

チャネルによる同時実行数コントロールの実装
#

package main

import (
	"context"
	"golang.org/x/sync/semaphore"
)

type VideoService struct {
	// 最大並列数を制限するためのセマフォ
	sem *semaphore.Weighted
}

func NewVideoService(maxConcurrent int64) *VideoService {
	return &VideoService{
		sem: semaphore.NewWeighted(maxConcurrent),
	}
}

func (s *VideoService) ProcessConcat(ctx context.Context, inputs []string, output string) error {
	// セマフォの枠を確保(空きが出るまでブロック、あるいはエラーを返す)
	if err := s.sem.Acquire(ctx, 1); err != nil {
		return fmt.Errorf("server busy, please try again later: %w", err)
	}
	defer s.sem.Release(1)

	// 動画連結処理を実行
	args := BuildConcatArgs(inputs[0], inputs[1], output)
	return ExecuteFFmpegSafe(ctx, args)
}

6. プロダクションチェックリスト
#

本番環境に動画連結機能を組み込む前に、以下の項目が考慮されているか確認してください。

  • 同時実行数制限: サーバースペックに合わせた並列処理制御(セマフォ等)があるか?
  • タイムアウト設計: context.WithTimeout により、FFmpegのゾンビプロセス化を防いでいるか?
  • Faststart化: ストリーム再生対応のため -movflags +faststart を指定しているか?
  • 一時ファイルのクリーンアップ: 連結用テキストや中間処理ファイルを defer os.Remove で確実に消去しているか?
  • エラーハンドリング: FFmpeg の終了コード(Exit Code)が 0 以外の場合にログと元ソースを保持しているか?

7. よくある質問(FAQ)
#

Q. Concat Demuxerで動画が連結されますが、繋ぎ目で一瞬映像が固まったり音飛びしたりします。
#

A. 連結する動画の解像度が同じでも、コーデックプロファイル、オーディオチャンネル数、ピクセルフォーマット(yuv420pとyuv422pなど)のいずれかが異なっている可能性があります。事前に ffprobe ですべてのパラメータが完全に一致しているか確認し、不一致の場合は Filter Complex による再エンコード(正規化)にフォールバックさせてください。

Q. os/exec で実行した FFmpeg のメモリ使用量を制限することはできますか?
#

A. Goのコードから直接外部プロセスの物理メモリを制限することはできません。Linux の cgroups や、コマンド実行時に ulimit を挟む、あるいは Docker コンテナ内で稼働させてコンテナレベルでメモリ制限を設定するのが実務上のアプローチです。

Q. VFR(可変フレームレート)の動画か確認するにはどうすればよいですか?
#

A. ffprobe でビデオストリームの has_b_frames やフレームレートの履歴を確認するか、実際にメタデータを比較します。スマホ撮影動画など、不特定多数のユーザーがアップロードする動画は「すべてVFRである」と仮定して、連結前に一律でCFR正規化処理を入れる設計が最も安全です。

Q. FFmpegの実行が非常に遅いです。高速化する方法はありますか?
#

A. 再エンコードが伴う場合は、GPUによるハードウェアアクセラレーション(NVIDIA NVENCやIntel QSVなど)を有効にすることで劇的に高速化できます。ただし、クラウドサーバーのGPUインスタンス代などコストが高くなるため、まずは動画解像度の縮小や、再エンコード処理の並列キュー管理での最適化を検討してください。


著者
ゆーふー
Web開発、インフラ、AI技術に興味があるエンジニアです。日々の学びを記録しています。

関連記事

👤 運営者プロフィール

運営者プロフィール画像

ゆーふー

メガベンチャーで働く現役Webエンジニア(歴約2年)。
フロントエンドからインフラ構築、セキュリティ対策まで、実務で得た「現場のリアルな技術知見」を発信しています。