Flutterでの360°ビデオのリフレーミング: 球面投影、クォータニオンキーフレーム、GPUエクスポート
生の正距円筒図法360°フッテージを、Flutterアプリ内で完全にスムーズでフレーム付きのフラットなビデオに変換する方法。
誰も教えてくれない問題
通常のビデオには、カメラが向けたフレームという、あなたに見せる明白なものが1つあります。360°ビデオにはそのようなものはありません。カメラはすべてを記録しました—X軸が経度でY軸が緯度である平らな正距円筒図法(2:1の画像)として保存されたピクセルの完全な球体です。そのフッテージをInstagram、YouTube、または電話の画面に表示する前に、カメラが意図的に答えようとしなかった質問に誰かが答える必要があります:
視聴者はどこを、いつ見るべきか?
「リフレーミング」とは、仮想カメラをその球体の中を時間とともに飛行させ—パン、チルト、ズームを行い—通常のフラットな16:9(または9:16、または2.35:1)ビデオとしてレンダリングする行為です。これは、GoPro Player、Insta360 Studio、およびAdobe PremiereのGoPro VR Reframeプラグインが構築されている機能です。
私たちはそれを完全にFlutterで構築しました。この記事は、本当に難しかった部分、すなわち投影数学、ジンバルロックフリー補間、±180°の継ぎ目、そして実際のAndroidデコーダーで動作する必要があるGPUエクスポートパイプラインについて説明します。
エンドツーエンドのフロー:
360ビデオのキャプチャ/インポート
→ 軽量なプレビュープロキシのトランスコード
→ ユーザーがタップ/描画/被写体を自動検出
→ オブジェクトトラッキングがパスを生成
→ キーフレームの生成+平滑化
→ フレームごとのカメラ向きをSLERP補間(ライブプレビュー)
→ ネイティブGPUでフラットなビデオにエクスポート
→ FFmpeg後処理(moov atom + A/V同期修正)
アーキテクチャの概要
私たちはシステムを3つの明確なレイヤーに保ち、数学がFlutterから独立してテストできるようにしました:
| レイヤー | 場所 | 役割 |
|---|---|---|
| モデル | lib/data/models/reframe/ | SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject |
| サービス | lib/services/reframe/ | SphericalProjection (レイキャスティング), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService |
| プレゼンテーション | lib/presentation/.../reframe/ | Riverpod ReframeNotifier, ネイティブOpenGL Spherical360Player, bbox描画オーバーレイ, キーフレームタイムライン |
黄金律:すべての三角法は、Flutterインポートなしの純粋なDartです。つまり、すべての投影および補間関数は、ウィジェットツリーなしでユニットテスト可能であり、同じ数学がライブプレビューとエクスポートの両方を駆動します。
1. その核心:正距円筒図法 ⟷ 球面
すべては一つのマッピングから始まります。正距円筒図法フレームは、単なる長方形であり、そこでは:
- X(0 → 幅)は経度(ヨー)を−180°から+180°まで掃引します
- Y(0 → 高さ)は緯度(ピッチ)を+90°(上/天頂)から−90°(下/天底)まで掃引します
SphericalCoordinatesは意図的に小さく、ヨーとピッチのみです。ロール、FOV、ズームは方向ではなくカメラに属します。これが機能全体の基盤となる変換です。
factory SphericalCoordinates.fromEquirectangular({
required double x, required double y,
required double videoWidth, required double videoHeight,
}) {
final normalizedX = x / videoWidth;
final normalizedY = y / videoHeight;
// Xはヨーにマッピングされます: 0->-180°、0.5->0°、1.0->180°
final yaw = (normalizedX - 0.5) * 360.0;
// Yはピッチにマッピングされます: 0->90° (上)、0.5->0° (地平線)、1.0->-90° (下)
final pitch = (0.5 - normalizedY) * 180.0;
return SphericalCoordinates(
yaw: yaw.clamp(-180.0, 180.0),
pitch: pitch.clamp(-90.0, 90.0),
);
}
逆変換はミラーイメージ(normalizedX = yaw/360 + 0.5)であり、追跡されたバウンディングボックスをプレイヤーに描き戻すために常に使用します。
「見ているものをタップ」 — 透視レイキャスティング
ユーザーは正距円筒図法の長方形と直接対話しません。彼らはレンダリングされた透視ビューと対話します。したがって、プレイヤー上の点をタップすると、仮想ピンホールカメラを通してレイをキャストし、球体上のどの方向をヒットしたかを見つける必要があります。それがSphericalProjection.screenToSphericalです。
final ndcX = (2.0 * screenPoint.dx / playerSize.width) - 1.0;
final ndcY = 1.0 - (2.0 * screenPoint.dy / playerSize.height);
final aspectRatio = playerSize.width / playerSize.height;
final tanHalfFov = math.tan(cameraFov * math.pi / 360.0); // half-angle
double rayX = ndcX * aspectRatio * tanHalfFov;
double rayY = ndcY * tanHalfFov;
double rayZ = 1.0;
// ...正規化し、レイをピッチ(X軸周り)で回転させ、次にヨー(Y軸周り)で回転させる...
final sphericalYaw = math.atan2(finalRayX, finalRayZ) * 180.0 / math.pi;
final sphericalPitch = math.asin(finalRayY.clamp(-1.0, 1.0)) * 180.0 / math.pi;
.clamp(-1.0, 1.0)はasinの前に付けると過剰に見えるかもしれませんが、浮動小数点数のドリフトにより1.0000001が渡され、asinはNaNを返します — そして単一のNaNはすべての下流フレームを汚染します。逆三角関数の周囲で防御的にクランプすることは、このコードベースにおける繰り返し現れるテーマです。
2. スムーズなカメラモーション:オイラー角ではなくクォータニオン
素朴なアプローチは、各キーフレームでヨー/ピッチを保存し、数値を線形補間することです。それはひどく見えます。オイラー角を補間すると、ジンバルロック、非一様な角速度、および極付近での不格好なスナップが発生します。
私たちは、SLERP(球面線形補間)を使用して向きをクォータニオンとして補間します。各方向は、ZYX Euler規約を介してクォータニオンに変換されます:
Quaternion toQuaternion() {
final yawRad = yaw * math.pi / 180.0;
final pitchRad = pitch * math.pi / 180.0;
final cy = math.cos(yawRad * 0.5); final sy = math.sin(yawRad * 0.5);
final cp = math.cos(pitchRad * 0.5); final sp = math.sin(pitchRad * 0.5);
return Quaternion(w: cy * cp, x: cy * sp, y: sy * cp, z: -sy * sp);
}
そして、SLERP自体には、すべての実装が必要とする2つの実運用レベルの安全弁があります:
static Quaternion slerp(Quaternion a, Quaternion b, double t) {
a = a.normalize(); b = b.normalize();
var dotProduct = a.dot(b);
if (dotProduct < 0.0) { b = -b; dotProduct = -dotProduct; } // 最短の半球を使用
if (dotProduct > 0.9995) { // ほぼ平行 -> LERPにフォールバック
return Quaternion(w: a.w + t*(b.w-a.w), x: a.x + t*(b.x-a.x),
y: a.y + t*(b.y-a.y), z: a.z + t*(b.z-a.z)).normalize();
}
final theta0 = math.acos(dotProduct);
final theta = theta0 * t;
final sinTheta = math.sin(theta);
final sinTheta0 = math.sin(theta0);
final s0 = math.cos(theta) - dotProduct * sinTheta / sinTheta0;
final s1 = sinTheta / sinTheta0;
return Quaternion(w: s0*a.w + s1*b.w, /* ...*/).normalize();
}
2つの非交渉的な詳細:
- 最短の半球 — qと−qは同じ向きを表します。ドット積が負の場合、いずれかの入力を否定しないと、カメラが球体の周りを遠回りします。
- ほぼ平行な場合のLERPフォールバック — 2つのキーフレームがほぼ同一の場合、sinTheta0 → 0となり、ほぼゼロで除算することになります。dot > 0.9995で単純なLERPにフォールバックすることで、破綻を防ぎます。
微妙だが重要な設計上の判断:私たちは方向はSLERPで補間し、FOV/ズームはLERPで補間し、ロールは角度LERPで補間します。球面補間は方向には適切なツールですが、ズームは単なるスカラーであり、線形に移動する必要があります:
final qInterp = Quaternion.slerp(from.direction.toQuaternion(), to.direction.toQuaternion(), t);
final interpDirection = SphericalCoordinates.fromQuaternion(qInterp).normalize();
final interpFov = _lerp(from.fov, to.fov, t);
final interpRoll = _lerpAngle(from.roll, to.roll, t); // ±180°を越えてラップ
final interpZoom = _lerp(from.zoom, to.zoom, t);
💡 クォータニオンからヨー/ピッチに戻す際には、「数値安定性のため(asinのドメイン問題を回避するため)」asinではなくatan2を使用します。SLERPの後、クォータニオンはごくわずかに非正規化される可能性があり、asinがNaNを投げるのに対し、atan2は gracefullyに劣化します。
イージング:手動で作成、入ってくるトランジションに
各キーフレームは、それへのトランジションのイージングカーブを所有します。カーブは手書きで記述されているため、すべてのプラットフォームで同一です:
case KeyframeEasing.linear: return t;
case KeyframeEasing.easeInOut: return t * t * (3 - 2 * t); // smoothstep S字カーブ
case KeyframeEasing.easeIn: return t * t; // 二次関数
case KeyframeEasing.easeOut: return 1 - (1 - t) * (1 - t);
3. ±180°の継ぎ目:すべての360°機能を悩ませるバグ
ここに落とし穴があります。ヨーは円上に存在します:+179°と−179°は358°ではなく2°離れています。被写体が球体の後ろを横切った瞬間、素朴な計算ではカメラが地球の半周をスナップしてしまいます。この単一の問題はシステムの4つの異なるレイヤーで現れ、それぞれに独自の修正が必要です。
レイヤー1 — プリミティブ。コードベース全体が依存する2つの小さなヘルパー:
static double normalizeYaw(double yaw) {
while (yaw > 180) { yaw -= 360; }
while (yaw < -180) { yaw += 360; }
return yaw;
}
// 「継ぎ目安全なトラッキングに不可欠...180°スナップを回避」
static double shortestYawDelta(double from, double to) {
double delta = to - from;
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
}
レイヤー2 — キーフレーム生成は、連続的な(無制限の)ヨーを蓄積するため、SLERPは常に最短経路を補間します。すべてのサンプルを±180°にクランプする代わりに、最短のデルタを合計し、意図的に実行値を180°を超えてドリフトさせます:
final normalizedContinuousYaw = SphericalCoordinates.normalizeYaw(continuousYaw);
final yawDelta = SphericalCoordinates.shortestYawDelta(normalizedContinuousYaw, rawCoords.yaw);
continuousYaw = continuousYaw + yawDelta; // 意図的に±180°を超える場合あり
coords = SphericalCoordinates(yaw: continuousYaw, pitch: rawCoords.pitch);
レイヤー3 — バウンディングボックスの再構築。ユーザーが継ぎ目をまたぐボックスを描画すると、4つのコーナーは+170°、+175°、−175°、−170°のようなヨーを報告します。SphericalProjectionはこれを検出し(任意のコーナー > 90°かつ任意のコーナー < −90°)、負の値を+360°シフトしてからそのスパンを測定することで、連続ヨーの「継ぎ目安全な」bboxを再構築します。
レイヤー4 — ライブプレイヤーはshortestYawDeltaを使用してターゲットに向かってスムーズに移動するため、継ぎ目を横切るドラッグでぎくしゃくすることはありません。
360°ツールを構築する際に一つ覚えておくべきこと:継ぎ目はエッジケースではなく、横断的な関心事です。ヨーが出現するあらゆる場所で考慮してください。
4. Insta360/GoProのような感覚にする
スムーズな補間は、技術的に正しいカメラをもたらします。しかし、良いカメラではありません。「感覚工学」の3つの要素がそのギャップを埋めます。
モーション制約 — 速度と加速度をクランプします。生の追跡データは不安定です。私たちはキーフレームをMotionConstraintsに通します。これは仮想カメラがどれだけ速く、どれだけ突然動けるかを制限します(例:maxYawSpeed = 120°/秒、maxPitchSpeed = 90°/秒、maxAcceleration = 180°/秒²)。cinematic()、responsive()、production()のプリセットが用意されています。これは「ハエを追跡する監視カメラ」と「安定した手を持つオペレーター」の違いです。
適応型FOV — 被写体に合わせるためのズーム。正距円筒図法空間で被写体の角サイズを測定し、フレームの目標割合を満たすようにFOVを選択します:
final angularWidth = (bboxWidth / videoWidth) * 360.0;
final angularHeight = (bboxHeight / videoHeight) * 180.0;
final objectAngularSize = math.max(angularWidth, angularHeight);
final idealFov = objectAngularSize / targetFillRatio; // targetFillRatio ≈ 0.4
return idealFov.clamp(minFov, maxFov); // 45°–120°
モーションリード + 顔→体ブレンド。私たちは、被写体が進む方向のbbox幅の約15%を先行させてフレーミングを移動させます。これにより、被写体が歩いている方向の端に固定されるのを防ぎます。そして顔検出が途切れた場合でも、体の中心にスナップするのではなく、最後に認識された顔の中心から体の中心へ約10フレームかけてラープします。
5. 2つのビデオトリック:プレビュープロキシ対フル解像度オリジナル
これは、システム全体を実際の電話で利用可能にするためのアーキテクチャ上の決定です。
360° GoPro/Insta360ファイルは、しばしば4096×2048 HEVCです。低予算のAndroidデコーダーは、オブジェクトトラッキングを同時に実行するどころか、それをスムーズに再生しようとするだけで詰まってしまいます。そのため、私たちはトランスコードされたプロキシ(約1440×720、libx264 -preset veryfast -crf 28)でトラッキングとライブプレビューを実行し、最終エクスポートのみにフル解像度のオリジナルを予約します。
// 「360°ビデオのMediaMetadataRetrieverよりも信頼性が高い
// (HEVC、高解像度など)の珍しいコーデックを使用することが多いため」
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048)は安全でない -> transcodeForPlayback()をトリガー
注意すべき点:キーフレームのバウンディングボックスはプロキシ座標で計算され、エクスポート前に元の座標にスケールアップする必要があります。このスケーリングを間違えると、エクスポートされたフレームはユーザーがプレビューした領域とわずかに異なるものになります。(メタデータと最初のフレーム抽出には、MediaMetadataRetrieverではなくffprobeを使用しています — Androidの内蔵レトリーバーは、360°カメラが生成する珍しいコーデックでは不安定です。)
6. GPUエクスポート:MethodChannelを介したOpenGL + MediaCodec
電話のCPU上でFFmpegのv360フィルターを介して4K球のすべてのフレームを再投影するのは、はるかに遅すぎます。(私たちはv360文字列を保持しています—それは投影の意味論、つまりv360=e:flat:...が「正距円筒図法入力、フラット出力」を意味することをきれいに文書化していますが、ライブパスではありません。)
実際のエクスポートは、MethodChannelを介してキーフレームをネイティブのOpenGL ES + MediaCodecレンダラーに渡します:
final result = await _methodChannel.invokeMethod('exportVideoGPU', {
'videoPath': resolvedVideoPath,
'outputPath': outputPath,
'keyframes': keyframesList
'initialOrientation': initialOrientationMap,
'width': settings.exportWidth,
'height': settings.exportHeight,
'fps': settings.outputFps.toInt(),
'includeAudio': settings.includeAudio,
if (isTrimmed) 'trimStartMs': trimStartMs,
if (isTrimmed) 'trimEndMs': trimEndMs
});
私たちはこれを機能チェック(Android API 21+、ハードウェアH.264エンコーダー、OpenGL ES 3.0)の背後に置き、EventChannelを介して進捗状況をストリームで返します。いくつか苦労して学んだ落とし穴があります:
- −90°のヨーオフセット。私たちのDartの規約では、ヨー = 0を正距円筒図法の画像の中央(u = 0.5)に配置します。OpenGLレンダラーは、ヨー = 0をu = 0.75に配置します。そのため、すべてのキーフレームのヨーは、チャンネルを越える際に−90°(そして±180°に再ラップ)で再マッピングされます — これはエクスポートチャンネルとライブプレイヤーチャンネルの両方に対称的に適用され、プレビューとエクスポートが一致するようにします。
- 退化したキーフレーム数。キーフレームがゼロの場合 → 初期姿勢から2つの同一のキーフレームを合成(静的エクスポート)。キーフレームが1つの場合 → それを複製します。レンダラーは常に少なくとも開始と終了を必要とします。
- トリミングはタイムスタンプを再ベース化します。トリミングされたエクスポートは、すべてのキーフレームをt − trimStartMsだけシフトします。
誰も予想しないFFmpeg後処理
GPUエクスポートがゴールだと思われるかもしれません。しかし、そうではありません — AndroidのMediaMuxerはmoov atomをファイルの末尾に書き込み、ソースのオーディオPTSオフセットを運ぶことができるためです。その結果:ExoPlayer(およびFlutterのvideo_player)は、クロックが動き続けている間、画像をフリーズさせます。そのため、GPUパスの後、FFmpegは高速でロスレスなクリーンアップを行います:
// ビデオをコピー(再エンコードなし -> 品質劣化ゼロ)、オーディオを再エンコードしてPTSを修正、
// moov atomを先頭に移動させて即時再生を可能にする。
-c:v copy -af "aresample=async=1:first_pts=0" -movflags +faststart
そして、それを守る残酷な小さな落とし穴:最初にオーディオトラックを検出します。なぜなら、オーディオトラックがないビデオにオーディオフラグを渡すと、一部のデバイスでFFmpegが永久にハングするからです。後処理全体は、タイムアウト時にキャンセルする60秒のタイムアウトでラップされています。
7. ライブプレビューもネイティブプレイヤーです
エディタ内のプレビューはFlutter CustomPaintではありません — AndroidView('your_app/spherical360player'のようなviewTypeを持つ)を介して埋め込まれたネイティブのOpenGLサーフェスであり、ビューごとの独自のメソッド/イベントチャンネルを持っています。Dart Spherical360PlayerControllerはヨー/ピッチ/ロール/FOVをミラーリングし、送信前に同じ−90°オフセットを適用します。
快適にするためのフィーリングの詳細:
- フレームレート非依存のスムージング — factor = 1 - exp(-speed * dt)なので、60fpsでも90fpsでもイージングは同じに見え、継ぎ目安全な最短経路ヨーが組み込まれています。
- フリック後の慣性/惰性、摩擦ベースの減衰 — 再生中は無効、なぜなら再生中はカメラはユーザーの最後のフリックではなくキーフレームに従う必要があるからです。
- 一つのGestureDetector。そのonScaleUpdateはパンとピンチズームを同時に行います。ダブルタップで再生/一時停止を切り替えます。FOVは30°〜120°に、ピッチは±90°に、コントローラ境界でクランプされます。
一つ小さな、しかし重要なUXの修正:「トラッキング中」から「キーフレーム生成中」に切り替える前に、Flutterが実際に「100%」を描画するように120ミリ秒の遅延を挿入します。これがないと、最終的な進捗フレームとステータス変更が同じマイクロタスクバッチに入り、ユーザーは完了を見たことになりません。
ステートマシン、簡単に言うと
これらすべては、Riverpod ReframeNotifierがステータスenumを駆動することで調整されます:
idle → ビデオ読み込み中 → トランスコード中
→ (検出中 | オブジェクト選択中 | プレイヤーにbbox描画中 | プレイヤーで自動検出中)
→ トラッキング中 → キーフレーム生成中 → 編集中 → エクスポート中 → 完了 | エラー
複数のエントリーポイントが同じトラッカーに供給されます:タップして検出(YOLOオーバーレイ)、手動bbox、またはInsta360スタイルのプレイヤーに直接描画するジェスチャー。これらはすべてKeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...)に収束し、そこから補間器がすべてのプレビューおよびエクスポートフレームを駆動します。
学んだこと
- 数学は純粋に保つ。投影/補間レイヤーにはFlutterインポートを含めない。これがあったからこそテスト可能でした。
- 方向にはクォータニオンSLERP;スカラーにはLERP。オイラー角を補間してはいけませんし、ズームをSLERPしてはいけません。
- ±180°の継ぎ目はエッジケースではなく、横断的な関心事です。プリミティブ、生成、投影、プレイヤーで再出現します。
- 一度、対称的に規約を調整する。DartとOpenGL間の−90°ヨーオフセットは、プレビューとエクスポートが一致するように、両方のチャンネル境界で適用されます。
- インタラクションにはプロキシ、出力にはオリジナル。両者の間で座標をスケールアップすることを忘れないでください。
- 「レンダリング完了」≠「正しく再生される」。moov atomの位置とオーディオPTSを修正するためのFFmpeg後処理を予算に組み込み、オーディオフラグを追加する前にプローブしてください。
- 「フィーリング」は機能です。モーション制約、適応型FOV、モーションリードは、「技術的にリフレームされた」ものと「プロが撮影したように見える」ものを分けるものです。
その結果:被写体をタップし、トラッキングさせ、キーフレームを調整し、きれいなフラットビデオをエクスポートできる、完全なオンデバイス360°リフレームエディター — Dartが十分に高速に実行できない作業のみを行う薄いネイティブGPUレイヤーを持つFlutterで構築されています。

