【Flutter】撮影した向きで動画を表示＆描画する

# はじめに
[前回投稿した内容](https://qiita.com/Linda_man/items/503cacdaac3bf04c1239 "【Flutter】画面に描画される位置がずれてしまう...")に加えて、新たな問題点があったため修正しました。
一部描画機能も修正しています。
スマホでの動作分析アプリを作成しています。アプリの詳細は前回の記事をご覧ください。

# 開発環境
今回の開発環境は以下の通りです。
**・Windows 11**
**・Flutter 3.22**
**・Dart 3.4**
**・Firebase 12.7**
**・Adroid Studio 17.0**

# エラー内容
エラーの内容は大きく２つあります。
１つ目は、撮影した向きで動画が表示されないことです。
２つ目は、描画される位置がずれてしまうことです。
## ①撮影した向きで動画が表示されない...
Android実機を用いて縦で動画撮影を行い、その動画を読み込むと横画面になって編集画面に表示されてしまうという問題が起きました。
今まではフリー動画（アスペクト比16:9）をダウンロードしてその動画を読み込み、描画機能などの確認を行っていました。
そのため肝心の、スマホで動画撮影→動画読み込みという手順を忘れていました...。
下のスクショが撮影時の動画（左）と、読み込み後画面に表示した時の動画（右）です。

　　　　　 <img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2FScreenshot_20241122_092515.png?alt=media&token=6ddc7341-fabc-4a6d-bbde-c3c3c943890e">　　　　　　　<img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2FScreenshot_20241122_110825.png?alt=media&token=f2ac7aab-8c27-4dfd-9604-ab656551ec61">

スクショのように撮影時とアスペクト比や動画の向きが変わってしまいます。
その原因は撮影時に縦画面で撮影をしても動画のメタデータは16:9のアスペクト比で保存されているからだと考えます。（スクショの設定画面を参照）

<img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2FScreenshot_20241122_092418.png?alt=media&token=1976e8c5-19f0-45ad-a8b6-dfc9ff42de62">

なぜこのようなことが起きるのかは以下の記事で詳しくまとめてあるため、ぜひご覧ください。

https://qiita.com/yoya/items/4e14f696e1afd5a54403

このようなことが新たな問題点です。これが原因で次の②の問題も発生しました。

## ②描画される位置がずれてしまう...
縦で撮影した動画が横画面で表示されてしまう、動画のアスペクト比が変わってしまう、ということは描画機能もずれてしまいます。
動画が90°回転されているため、上に線を引くと左へ線が引かれます。
そのため、描画機能ももう一度見直すことになりました。

# 修正内容
エラーに対しての修正内容は大きく２つあります。
１つ目のエラーに対しては、動画のメタデータから回転補正をかけることです。
２つ目のエラーに対しては、描画の向きも回転させて動画の表示サイズを取得することです。
## ①動画のメタデータから回転補正をかける
VideoUtils.getVideoRotation メソッドを使用して、メタデータから動画の回転情報を取得しました。
このメソッドは、Flutter アプリケーション内でネイティブプラットフォーム（Android または iOS）と通信するために使用される MethodChannel を利用しています。
この回転情報をもとに動画を縦画面で表示しました。
以下に処理の流れを詳しく説明します。

### 処理フロー
１．VideoUtils.getVideoRotation の処理（Flutterから呼び出し）
２．ネイティブコード側の処理（例：Android）
３. 動画回転情報取得（Flutterに結果を返す）
４. 動画の表示向きを変更

### コード詳細
#### １．VideoUtils.getVideoRotation の処理
```video_utils.dart
import 'package:flutter/services.dart';

class VideoUtils {
  static const MethodChannel _channel = MethodChannel('video_rotation');

  static Future<int> getVideoRotation(String path) async {
    final int rotation = await _channel.invokeMethod('getVideoRotation', {"path": path});
    return rotation;
  }
}
```

・MethodChannel を使用してネイティブコードにリクエストを送信します。
・メソッド名として 'getVideoRotation' を指定し、動画ファイルのパスを渡しています（{"path": path}）。
・ネイティブコード（Android または iOS）で、このリクエストに応じて回転情報を取得し、その値を Flutter 側に返します。

#### ２．ネイティブコード側の処理（例：Android）
以下は、getVideoRotation メソッドの処理を Android 用に実装した例です。
Flutter アプリから MethodChannel を使って動画の回転情報を取得するための処理です。MediaMetadataRetriever を使い、動画のメタデータから回転角度（0, 90, 180, 270）を取得します。
```MainActivity.kt
package com.example.sample01

import android.media.MediaMetadataRetriever
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "video_rotation"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            if (call.method == "getVideoRotation") {
                val path = call.argument<String>("path")
                result.success(getVideoRotation(path ?: ""))
            }
        }
    }

    private fun getVideoRotation(path: String): Int {
        val retriever = MediaMetadataRetriever()
        retriever.setDataSource(path)
        val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
        retriever.release()
        return rotation?.toIntOrNull() ?: 0
    }
}
```

***◎MethodChannel のセットアップ***
・CHANNEL = "video_rotation" を介して Flutter と通信。
・Flutter から "getVideoRotation" メソッドが呼ばれると動画の回転角度を計算する getVideoRotation() を実行。

***◎動画の回転情報取得***
・Android の MediaMetadataRetriever を使用し、METADATA_KEY_VIDEO_ROTATION から回転角度を取得。
・成功すれば回転角度（0, 90, 180, 270）を返し、失敗時はデフォルト値 0 を返す。

***◎結果を Flutter に返す***
・計算した回転角度を result.success(rotation) で Flutter 側に返す。

#### ３. 動画回転情報取得
以下、回転情報をFlutter側で取得した際の例です。
```editscreen.dart
  @override
  void initState() {
    super.initState();
    _titleController = TextEditingController(text: widget.title);
    _memoController = TextEditingController(text: widget.memo);
    _categoryController = TextEditingController(text: widget.category);

    // 動画ファイルまたはURLを使用して初期化
    if (widget.videoFile != null) {
      _controller = VideoPlayerController.file(widget.videoFile!)
        ..initialize().then((_) async {
          // 動画の回転情報を取得
          _rotationDegrees = await VideoUtils.getVideoRotation(widget.videoFile!.path);
          setState(() {});
        });
    } else if (widget.videoUrl.isNotEmpty) {
      _controller = VideoPlayerController.network(widget.videoUrl)
        ..initialize().then((_) async {
          // 動画の回転情報を取得
          _rotationDegrees = await VideoUtils.getVideoRotation(widget.videoUrl);
          setState(() {});
        });
    }
    _loadVideoData(widget.videoId);
    _fetchCategories();
  }
```

１，２の手順で、Flutter アプリで getVideoRotation を呼び出し、MethodChannel を介してネイティブプラットフォームにリクエストを送信。ネイティブコードが動画メタデータから回転情報を抽出し、Flutter に返すことに成功すると、Flutter 側で回転情報を取得できます。

#### ４. 動画の表示向きを変更
以下のコードが、_rotationDegrees （取得した回転情報）に基づいて動画を回転させている部分です。こちらはbuildメソッドの一部です。
```editscreen.dart
Transform.rotate(
  angle: _rotationDegrees * pi / 180,
  child: FittedBox(
    fit: BoxFit.contain,
    alignment: Alignment.center,
    child: SizedBox(
      width: isRotated
          ? _controller!.value.size.height
          : _controller!.value.size.width,
      height: isRotated
          ? _controller!.value.size.width
          : _controller!.value.size.height,
      child: VideoPlayer(_controller!),
    ),
  ),
),

```

***◎回転角度の適用***
・_rotationDegrees * pi / 180 に基づき、動画を回転させます。

***◎動画のスケーリング***
・回転後の動画が画面内に収まるよう、FittedBox と BoxFit.contain を使ってスケール調整をします。

***◎正しいアスペクト比の保持***
・SizedBox の width と height には、動画が 縦向きか横向きか（isRotated）に基づいてサイズが設定されます。
    ・縦向きの動画（回転 90° または 270°）なら、幅と高さを入れ替え。
    ・横向きの動画（回転 0° または 180°）ならそのまま使用。

### 修正結果
撮影した時と同じように縦画面で、アスペクト比も9:16で表示することができました。

<img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2FScreenshot_20241122_154625.png?alt=media&token=e7705984-59dd-4fcd-96fa-e6f39db569a0">

## ②描画の向きを回転させて動画の表示サイズを取得する
動画の向きを修正したのですが、動画を回転させたため描画機能ももう一度見直す必要がありました。
行ったこととしては、
・動画表示と同様に描画の向きも回転させる
・動画表示領域のwidgetSizeを取得する
・画面に表示されているときの動画のサイズを取得する
・描画機能全体のコード修正
以上４点です。

### コード詳細
#### １．動画表示と同様に描画の向きも回転させる
動画の表示向きを_rotationDegreesに基づいて回転させたのと同様に描画の向きも回転させます。
```editscreen.dart
Transform.rotate(
  angle: _rotationDegrees * pi / 180,
  child: FittedBox(
    key: videoKey, //GlobalKey
    fit: BoxFit.contain,
    alignment: Alignment.center,
    child: SizedBox(
      width: isRotated
          ? _controller!.value.size.height
          : _controller!.value.size.width,
      height: isRotated
          ? _controller!.value.size.width
          : _controller!.value.size.height,
      child: CustomPaint(
        painter: DrawingPainter(_drawings, _rotationDegrees),
      ),
    ),
  ),
),
```
#### ２．動画表示領域のwidgetSizeを取得する
widgetSizeは描画の座標を求める際に動画周りの余白を計算するのに使います。
以前までのwidgetSizeの算出方法は以下の通りです。
```dart
final widgetSize = Size(constraints.maxWidth, constraints.maxHeight);
```
こちらの算出方法ではアイコンを画面下のアイコンを含めた領域の
そのため、以下の修正を加えて画面表示領域のwidgetSizeを取得しました。
```dart
final int iconRowCount = orientation == Orientation.portrait ? 3 : 1;
const int seekBarHeight = 9;
final widgetSize = Size(constraints.maxWidth, constraints.maxHeight - (iconRowHeight * iconRowCount) - seekBarHeight);
```
スクショの赤色の範囲が修正前（左）、黄色の範囲が修正後（右）です。
これにより動画表示領域のwidgetSizeを取得できました。

　　　　　 <img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2F%E7%94%BB%E5%83%8F4.png?alt=media&token=a09d7042-7783-479f-9f95-e74bf0403dea">　　　　　　　<img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2F%E7%94%BB%E5%83%8F2.png?alt=media&token=0b180d95-6d1c-40fb-8069-6de7108efb56">

#### ３．画面に表示されているときの動画のサイズを取得する
動画を画面に表示する際、widgetに収まるように動画のサイズを縮小させています。
どのくらい縮小させたのかという値に基づいて描画位置の座標を変換します。
その時に必要なのが画面に表示されている動画のサイズです。
以下のコードは描画の向きを回転させた時と同じものです。
```dart
Transform.rotate(
  angle: _rotationDegrees * pi / 180,
  child: FittedBox(
    key: videoKey, //GlobalKey
    fit: BoxFit.contain,
    alignment: Alignment.center,
    child: SizedBox(
      width: isRotated
          ? _controller!.value.size.height
          : _controller!.value.size.width,
      height: isRotated
          ? _controller!.value.size.width
          : _controller!.value.size.height,
      child: CustomPaint(
        painter: DrawingPainter(_drawings, _rotationDegrees),
      ),
    ),
  ),
),
```
こちらの key: videokey で動画の表示エリアのサイズを取得します。
具体的な流れは以下の通りです。

***・videokeyを指定***
動画の描画領域を持つウィジェット（ここでは FittedBox）に videoKey を設定します。

***・videoKey.currentContext を利用してサイズを取得***
WidgetsBinding.instance.addPostFrameCallback などでフレーム描画後に RenderObject を取得し、そのサイズや位置を調査します。

***・取得したサイズを基に後続の処理を調整***
動画の回転やスケーリングに基づいて、タッチ操作やカスタム描画の位置を調整します（後述）。

buildメソッドに以下のような処理を加えて、_displaySize（表示されている動画のサイズ）を取得しました。
```dart
WidgetsBinding.instance.addPostFrameCallback((_) {
  final renderObject = videoKey.currentContext?.findRenderObject();
  if (renderObject is RenderBox) {
    final Size rawDisplaySize = renderObject.size;

    // 回転を考慮して幅と高さを切り替える
    final Size actualDisplaySize = (_rotationDegrees == 90 || _rotationDegrees == 270)
        ? Size(rawDisplaySize.height, rawDisplaySize.width)
        : rawDisplaySize;

    // 取得したサイズをセットする
    setState(() {
      _displaySize = actualDisplaySize;
    });
  }
}
```

#### ４．描画機能全体のコード修正
ここまでの、描画の向きを回転したこと、動画表示領域のwidgetSizeを取得したこと、表示動画のサイズを取得したことを踏まえて、描画機能も修正しました。
```dart
void _onPanStart(DragStartDetails details, Size widgetSize) {
  final videoSize = _controller!.value.size;
  final scaledPosition = _scalePosition(details.localPosition, widgetSize);
  final adjustedPosition = _getAdjustedPosition(scaledPosition, widgetSize, videoSize);

  setState(() {
    if (_drawingMode != DrawingMode.none) {
      _drawings.add(Drawing(
        points: [adjustedPosition],
        color: _selectedColor,
        mode: _drawingMode,
      ));
    }
  });
}

void _onPanUpdate(DragUpdateDetails details, Size widgetSize) {
  final videoSize = _controller!.value.size;
  final scaledPosition = _scalePosition(details.localPosition, widgetSize);
  final adjustedPosition = _getAdjustedPosition(scaledPosition, widgetSize, videoSize);

  setState(() {
    if (_drawingMode != DrawingMode.none) {
      _drawings[_drawings.length - 1].points.add(adjustedPosition);
    }
  });
}

void _onPanEnd(DragEndDetails details, Size widgetSize) {
  final videoSize = _controller!.value.size;
  if (_drawingMode == DrawingMode.line || _drawingMode == DrawingMode.angle) {
    final scaledPosition = _scalePosition(details.localPosition, widgetSize);
    final adjustedPosition = _getAdjustedPosition(scaledPosition, widgetSize, videoSize);

    setState(() {
      final points = _drawings.last.points;
      if (points.length == 1) {
        points.add(adjustedPosition);
      }
      if (_drawingMode == DrawingMode.angle && points.length == 2) {
        points.add(adjustedPosition);
      }
      _drawings[_drawings.length - 1] = Drawing(
        points: points,
        color: _selectedColor,
        mode: _drawingMode,
      );
    });
  }
}

Offset _getAdjustedPosition(Offset scaledPosition, Size widgetSize, Size videoSize) {
  Offset adjusted;

  switch (_rotationDegrees) {
    case 90:
      adjusted = Offset(scaledPosition.dy, videoSize.width - scaledPosition.dx);
      break;
    case 270:
      adjusted = Offset(videoSize.height - scaledPosition.dy, scaledPosition.dx);
      break;
    case 180:
      adjusted = Offset(videoSize.height - scaledPosition.dy, videoSize.width - scaledPosition.dx);
      break;
    default:
      adjusted = scaledPosition;
      break;
  }
  return adjusted;
}

Offset _scalePosition(Offset localPosition, Size widgetSize) {
  final videoSize = _controller!.value.size;
  
  // 回転後の表示サイズを取得
  final Size displaySize = _displaySize!;

  final double displayAspectRatio = displaySize.width / displaySize.height;
  final double widgetAspectRatio = widgetSize.width / widgetSize.height;
  final double videoAspectRatio = videoSize.width / videoSize.height;

  double scale;
  double offsetX = (widgetSize.width - displaySize.width) / 2;
  double offsetY = (widgetSize.height - displaySize.height) / 2;

  if (widgetAspectRatio > videoAspectRatio) {
    scale = displaySize.height / videoSize.height;
  } else {
    scale = displaySize.width / videoSize.width;
  }

  final double adjustedX = (localPosition.dx - offsetX) / scale;
  final double adjustedY = (localPosition.dy - offsetY) / scale;

  // 回転角度に応じたクリッピング
  double clampedX;
  double clampedY;

  clampedX = adjustedX.clamp(0.0, videoSize.width);
  clampedY = adjustedY.clamp(0.0, videoSize.height);

  return Offset(clampedX, clampedY);
}
```
***・_onPanStart***
ユーザーがタッチ操作を始めた時に呼び出されます。
描画の開始点（最初の位置）を保存します。

***・_onPanUpdate***
タッチ操作が移動した時に呼び出されます。
ユーザーがタッチを移動した際に、描画が連続するようにします。

***・_onPanEnd***
タッチ操作が終了した時に呼び出されます。
特定の描画モードにおける描画完了時の処理を行います。

***・_getAdjustedPosition***
スケーリングされた位置（scaledPosition）を動画の回転に応じて調整します。
動画が回転している場合、座標系もそれに合わせて回転させるための処理を行います。

***・_scalePosition***
ユーザーがタッチしたlocalPosition（画面上のタッチ位置）を、動画の実際の座標系に変換します。
アスペクト比の違いや表示エリアの余白を考慮して正確な座標を取得します。

- ⬛ `videoSize`：読み込んだ動画のサイズ  
- 🟦 `scale`：videoSizeからdisplaySizeへの縮小率  
- 🟨 `widgetSize`：動画表示領域のwidgetのサイズ  
- 🟧 `displaySize`：動画の画面表示サイズ  
- 🟩 `offsetX`：widgetSizeとdisplaySizeの横幅の余白  
- 🟥 `offsetY`：widgetSizeとdisplaySizeの高さの余白  


<img width="" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2F%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202024-11-25%20102824.png?alt=media&token=402cb9dc-71fa-4572-b947-2466b35bfde8">

### 修正結果
スクショの通り、動画の端までズレがなく描画することができました。
<img width="200" src="https://firebasestorage.googleapis.com/v0/b/fir-test-4e3cb.appspot.com/o/Qiita%2FScreenshot_20241125_110011.png?alt=media&token=10452603-4351-4af4-a8f2-06702b793606">

# さいごに
今回は、縦撮影時の動画表示問題から描画機能の修正を記事にしました。
最後までご覧いただきありがとうございました。