hanzochang
hanzochang
はじめに
対象読者
動画の内部構造とキーフレーム
キーフレーム(Iフレーム)とは
GOP(Group of Pictures)構造
時間ベース分割で発生する問題
動画チャンク分割の落とし穴
時間ベース分割の問題点
Premiere Proでの表示問題
フレーム単位分割による解決策
NTSC 29.97fps動画での正確な計算
動的フレーム数計算の実装
ffmpegでのフレーム単位分割実装
fluent-ffmpegバグの回避策
動画分析ワークフローへの統合
自動判定とフレーム分割の切り替え
LLMワークフローでの時間精度向上
実装結果の検証
品質確認の実装
Premiere Pro互換性の確認
チャンクのフレーム数確認
期待値: 1800フレーム(60秒チャンクの場合)
パフォーマンス最適化
並列処理での効率化
トラブルシューティング
よくある問題と解決策
まとめ
参考資料
https://s3.ap-northeast-1.amazonaws.com/hanzochang.com/_v2/1767068488049_xseimivrla.png

動画AI・LLM開発|ffmpegを使った動画チャンク(chunking)の落とし穴

動画LLM開発で発生する時間精度問題とその解決策。NTSC 29.97fpsでのフレーム単位分割手法により、Premiere Proで正確に表示される60秒チャンクを実現する方法を解説します。

公開日2025.12.30

更新日2025.12.30

はじめに

動画AI・LLM開発では、動画を一定時間のチャンクに分割する処理が必須です。しかし、単純な時間ベース分割では予想外の問題が発生します。

実際に発生した問題の例

  • 60秒で分割したはずが、54.40秒、2.72秒、60.01秒のバラバラな長さになる
  • Premiere Proで54:10、59:28と表示され、期待する1:00:00にならない
  • LLMへの時間情報が不正確になり、分析精度が低下する

この記事では、動画チャンク分割の落とし穴と、フレーム単位による解決策を実装レベルで解説します。

対象読者

  • 動画AI・LLMアプリケーションを開発している方
  • ffmpegを使った動画処理に課題を感じている方
  • 動画編集ソフトとの互換性を重視する方

前提知識

  • JavaScriptまたはTypeScriptの基本的な理解
  • ffmpegの基本的な使用経験
  • 動画のフレームレートに関する基本知識

動画の内部構造とキーフレーム

動画チャンク分割の問題を理解するには、まず動画の内部構造を知る必要があります。

キーフレーム(Iフレーム)とは

動画ファイルは、効率的な圧縮のために以下の3種類のフレームで構成されています:

🔧 キーフレーム(Iフレーム)

  • 完全な画像情報を持つフレーム
  • 他のフレームに依存せず、単独で画像を再構成可能
  • ファイルサイズが大きいため、1〜2秒間隔で配置

📊 予測フレーム(Pフレーム)

  • 前のフレームとの差分情報のみ保持
  • キーフレームやPフレームを参照して画像を構成

🎯 双予測フレーム(Bフレーム)

  • 前後のフレームを参照して差分を記録
  • 最も効率的な圧縮が可能

GOP(Group of Pictures)構造

これらのフレームは「GOP」という単位でグループ化されています:

001

GOP構造の重要なポイント

  • 各GOPはキーフレーム(I)から開始
  • GOP内のフレームは相互依存関係がある
  • 動画カットはキーフレーム位置でのみ正確に実行可能

時間ベース分割で発生する問題

2 上記のように、指定した時間位置にキーフレームが存在しない場合:

問題1: 不正確なカット位置

指定時間: 60.00秒
実際のキーフレーム: 59.73秒 または 60.27秒
結果: 59.73秒 または 60.27秒でカット

問題2: 依存フレームの欠損

GOP途中でカット → 参照フレームが不完全 → 再生エラーまたは品質劣化

動画チャンク分割の落とし穴

時間ベース分割の問題点

一般的な動画分割では、以下のようなアプローチを取りがちです:

// ❌ 問題のあるアプローチ
ffmpeg(inputPath)
  .seekInput(startTime) // 開始時刻
  .duration(chunkDuration) // 長さ
  .save(outputPath)

この方式で発生する問題

🔧 GOP境界による時間のズレ

  • 前述したように、正確なカットはキーフレーム位置でのみ可能
  • 時間指定でのカットは最近のキーフレームに"スナップ"される
  • 60秒指定でも実際は59.73秒や60.27秒でカットされる

⚠️ NTSCフレームレート(29.97fps)の特殊性

  • 29.97fpsは厳密には30000/1001fps
  • 1秒 = 29.97フレームではなく、複雑な計算が必要
  • 時間ベースの計算では累積誤差が発生

fluent-ffmpegライブラリのバグ

  • -ss 0(開始時刻0秒)で分割すると、期待より短いチャンクが生成される
  • 最初のチャンクが54秒になる現象が確認されている

Premiere Proでの表示問題

時間ベース分割されたチャンクをPremiere Proに読み込むと:

期待値: 1:00:00, 1:00:00, 1:00:00...
実際  : 54:10,   59:28,   59:29...

この表示の違いは、プロ向け編集ソフトがフレーム単位で時間を管理しているためです。

フレーム単位分割による解決策

NTSC 29.97fps動画での正確な計算

60秒のチャンクをフレーム単位で正確に作成するには:

// ✅ 正しいアプローチ:フレーム単位での計算
function calculateFramesForNTSC(targetDurationSeconds: number): number {
  // 29.97fps = 30000/1001fps
  // Premiere互換性のため、30フレーム/秒として計算
  return Math.round(targetDurationSeconds * 30)
}

// 例:60秒 = 1800フレーム(Premiere Proで1:00:00と表示)
const framesFor60s = calculateFramesForNTSC(60) // 1800
const framesFor30s = calculateFramesForNTSC(30) // 900
const framesFor90s = calculateFramesForNTSC(90) // 2700

動的フレーム数計算の実装

様々なチャンク時間に対応できる汎用的な実装:

export function calculateOptimalFrameCount(fps: number, targetDuration: number): number {
  if (Math.abs(fps - 29.97002997) < 0.001) {
    // NTSC 29.97fps: Premiere互換のため30フレーム/秒
    return Math.round(targetDuration * 30)
  } else if (Math.abs(fps - 30) < 0.001) {
    // 30fps: 正確に秒数 × 30フレーム
    return Math.round(targetDuration * 30)
  } else if (Math.abs(fps - 23.976023976) < 0.001) {
    // NTSC Film 23.976fps: 24フレーム/秒として計算
    return Math.round(targetDuration * 24)
  } else if (Math.abs(fps - 24) < 0.001) {
    // Film 24fps
    return Math.round(targetDuration * 24)
  } else {
    // その他: 実際のFPS
    return Math.round(targetDuration * fps)
  }
}

ffmpegでのフレーム単位分割実装

フレーム番号を直接指定する分割方法:

export async function cutVideoByFrames(
  inputPath: string,
  chunkIndex: number,
  targetDuration: number,
  outputPath: string,
  fps: number,
): Promise<void> {
  const framesPerChunk = calculateOptimalFrameCount(fps, targetDuration)
  const startFrame = chunkIndex * framesPerChunk
  const endFrame = startFrame + framesPerChunk

  console.log(
    `📹 Frame-based cut: ${startFrame}-${endFrame} (${framesPerChunk} frames @ ${fps.toFixed(2)}fps)`,
  )

  return new Promise((resolve, reject) => {
    ffmpeg(inputPath)
      .videoFilters([
        `trim=start_frame=${startFrame}:end_frame=${endFrame}`,
        'setpts=PTS-STARTPTS', // タイムスタンプをリセット
      ])
      .audioFilters([
        `atrim=start=${startFrame / fps}:end=${endFrame / fps}`,
        'asetpts=PTS-STARTPTS', // 音声タイムスタンプもリセット
      ])
      .outputOptions([
        '-c:v libx264', // H.264エンコード
        '-preset medium', // バランス重視
        '-crf 18', // 高品質設定
        '-pix_fmt yuv420p', // 互換性確保
        '-r 30000/1001', // 正確なNTSCフレームレート
        '-c:a aac', // AAC音声
        '-ar 48000', // サンプリングレート
        '-ac 2', // ステレオ
        '-b:a 192k', // 音声ビットレート
        '-map_metadata -1', // メタデータクリア
        '-movflags +faststart', // Web配信最適化
        '-y', // 上書き確認なし
      ])
      .output(outputPath)
      .on('end', () => {
        console.log(`✅ Frame-accurate cut complete → ${outputPath}`)
        resolve()
      })
      .on('error', (err) => {
        console.error(`❌ Frame-based cut failed: ${err.message}`)
        reject(err)
      })
      .run()
  })
}

fluent-ffmpegバグの回避策

-ss 0での問題を回避する実装:

// fluent-ffmpegの-ss 0バグを回避
if (startTime === 0) {
  // 最初のチャンク: -ssを使わない
  ffmpegCommand.outputOptions([`-t ${duration}`])
} else {
  // 2番目以降: 通常通り-ssを使用
  ffmpegCommand.inputOptions([`-ss ${startTime}`]).outputOptions([`-t ${duration}`])
}

動画分析ワークフローへの統合

自動判定とフレーム分割の切り替え

NTSC動画を自動検出してフレーム分割に切り替える実装:

async function splitVideoIntelligently(
  inputPath: string,
  chunkDuration: number,
  outputDir: string,
) {
  // 動画のFPSを取得
  const fps = await getVideoFPS(inputPath)

  // NTSC 29.97fpsの場合はフレーム単位分割
  const useFrameBasedCutting = Math.abs(fps - 29.97002997) < 0.001

  if (useFrameBasedCutting) {
    console.log('🎯 NTSC動画を検出:フレーム単位分割を使用')
    const framesPerChunk = Math.round(chunkDuration * 30)
    const timeDisplay = formatTimeDisplay(chunkDuration)
    console.log(`   ${framesPerChunk}フレーム = Premiere ${timeDisplay}`)

    return await splitByFrames(inputPath, chunkDuration, outputDir, fps)
  } else {
    console.log('📺 標準動画:時間ベース分割を使用')
    return await splitByTime(inputPath, chunkDuration, outputDir)
  }
}

LLMワークフローでの時間精度向上

分割されたチャンクをLLMに渡す際の時間情報の正確化:

interface VideoChunk {
  chunkId: number
  startFrame: number
  endFrame: number
  actualDurationSeconds: number
  filePath: string
}

// LLMプロンプトでの時間情報
function createAnalysisPrompt(chunk: VideoChunk, fps: number): string {
  const startTime = chunk.startFrame / fps
  const endTime = chunk.endFrame / fps

  return `
この動画チャンクを分析してください:
- 開始時刻: ${startTime.toFixed(2)}- 終了時刻: ${endTime.toFixed(2)}- 総時間: ${chunk.actualDurationSeconds.toFixed(2)}- フレーム範囲: ${chunk.startFrame} - ${chunk.endFrame}

各分析セグメントの時間は10秒間隔で正確に区切られています。
`
}

実装結果の検証

品質確認の実装

作成されたチャンクが期待通りの品質か確認する方法:

async function verifyChunkQuality(chunks: VideoChunk[]) {
  console.log('🔍 チャンク品質検証を開始...')

  for (const chunk of chunks) {
    // 実際のフレーム数を確認
    const actualFrameCount = await getVideoFrameCount(chunk.filePath)
    const expectedFrames = chunk.endFrame - chunk.startFrame

    // 時間精度を確認
    const actualDuration = await getVideoDuration(chunk.filePath)
    const expectedDuration = expectedFrames / 30 // NTSC基準

    const frameAccuracy = Math.abs(actualFrameCount - expectedFrames)
    const timeAccuracy = Math.abs(actualDuration - expectedDuration)

    if (frameAccuracy === 0 && timeAccuracy < 0.1) {
      console.log(`✅ チャンク${chunk.chunkId}: フレーム精度完璧`)
    } else {
      console.warn(`⚠️ チャンク${chunk.chunkId}: 精度に問題あり`)
      console.warn(`   フレーム差分: ${frameAccuracy}`)
      console.warn(`   時間差分: ${timeAccuracy.toFixed(3)}`)
    }
  }
}

Premiere Pro互換性の確認

作成したチャンクがプロ向けソフトで正しく認識されるかの確認:

# チャンクのフレーム数確認
ffprobe -v error -select_streams v:0 -count_packets \
  -show_entries stream=nb_read_packets -of csv=p=0 chunk.mp4

# 期待値: 1800フレーム(60秒チャンクの場合)

パフォーマンス最適化

並列処理での効率化

複数チャンクを同時処理する実装:

async function splitVideoParallel(
  inputPath: string,
  chunkDuration: number,
  outputDir: string,
  concurrency: number = 3,
): Promise<VideoChunk[]> {
  const totalDuration = await getVideoDuration(inputPath)
  const numChunks = Math.ceil(totalDuration / chunkDuration)
  const fps = await getVideoFPS(inputPath)

  console.log(`🚀 ${numChunks}個のチャンクを${concurrency}並列で処理開始`)

  // チャンク作成タスクを生成
  const tasks = Array.from(
    { length: numChunks },
    (_, i) => () =>
      cutVideoByFrames(
        inputPath,
        i,
        chunkDuration,
        path.join(outputDir, `chunk_${i.toString().padStart(4, '0')}.mp4`),
        fps,
      ),
  )

  // 並列実行(concurrency制限付き)
  const results = await Promise.allSettled(
    tasks.map(
      (task, i) =>
        new Promise((resolve) => {
          setTimeout(() => resolve(task()), i * 100) // 100ms間隔でスタート
        }),
    ),
  )

  // 結果の集計
  const successCount = results.filter((r) => r.status === 'fulfilled').length
  console.log(`${successCount}/${numChunks}個のチャンク作成完了`)

  return chunks
}

トラブルシューティング

よくある問題と解決策

💡 問題1: 最初のチャンクだけ短くなる

// 解決策: startTimeが0の場合の特別処理
if (startTime === 0) {
  // -ssオプションを使わない
  ffmpegCommand.outputOptions([`-t ${duration}`])
} else {
  ffmpegCommand.inputOptions([`-ss ${startTime}`])
}

⚠️ 問題2: 音声と映像の同期ずれ

// 解決策: 音声フィルターも同時に適用
.audioFilters([
  `atrim=start=${startFrame / fps}:end=${endFrame / fps}`,
  'asetpts=PTS-STARTPTS'
])

問題3: エンコード設定による品質低下

// 解決策: 高品質設定の使用
.outputOptions([
  '-c:v libx264',
  '-crf 18',           // 高品質(0-51、低いほど高品質)
  '-preset medium',    // エンコード速度と品質のバランス
  '-pix_fmt yuv420p'   // 互換性確保
])

まとめ

動画AI・LLM開発における動画チャンク分割では、時間ベースではなくフレーム単位でのアプローチが重要です。

重要なポイント

  • NTSC 29.97fps動画では30フレーム/秒として計算
  • fluent-ffmpegの-ss 0バグに注意
  • フレーム単位分割でPremiere Pro互換性を確保
  • 音声同期も考慮したフィルター適用

実装で得られる効果

  • ✅ 正確な時間表示(Premiere Proで1:00:00)
  • ✅ LLM分析での時間情報精度向上
  • ✅ プロ向け編集ソフトとの完全互換性
  • ✅ 長時間動画での累積誤差解消

この手法により、動画AI・LLMアプリケーションの分析精度と編集ワークフローとの互換性を両立できます。

参考資料

picture
hanzochang - 半澤勇大
慶應義塾大学卒業後、Webプランナーとして勤務。 ナショナルクライアントのキャンペーンサイトの企画・演出を担当。 その後開発会社に創業メンバーとして参加。 Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立し、 2023年にWeb3に特化した開発会社として法人化しました。 現在はWeb3アプリ開発を中心にAI開発フローの整備を行っています。
また、趣味で2017年ごろより匿名アカウントでCryptoの調査等を行い、 ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信していました。
X (Twitter)

お問い合わせはこちらから

ご希望に応じて職務経歴書や過去のポートフォリオを提出可能ですので、必要な方はお申し付けください。
また内容とによっては返信ができない場合や、お時間をいただく場合がございます。あらかじめご了承ください。