【Flutter】GoogleVideoIntelligenceAPIで動画自動トリミングを実装してみた

# ⚪︎はじめに
BMXという「スケボーの自転車バージョン」のようなスポーツに特化した、MapSNSアプリ「Wavy'sMap」をFlutterで制作しました。
プログラミング歴半年の小僧が、そのアプリ内で実装した「**動画の自動トリミング機能**」をどのように実装したかを書きます。

# ⚪︎何をしたかったのか
BMXの練習動画を投稿する際に行う、不要な箇所を取り除くトリミング作業が面倒だった為、「動画をぶち込んだら勝手にトリミングしてくれたら良いなあ〜」という感じ
行ってみよっ🤪☝️
![Qiita.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1096105/d66a27fd-d9db-9932-81f5-63ea69112a2f.gif)

# ⚪︎構想
アプリ自体は1ヶ月弱で制作し、自動トリミング機能単体で2週間程かかりました。
## 記事がねえ
動画の自動トリミング機能実装に関する記事がほとんどありませんでした。
プログラミングをしっかり学び始めて約半年の僕は、既に記事のある実装方法が分かっている物しか作った事がありませんでした。
自分で考えるしかない。。。
## 実装方法を考える
AIを使えば出来そうだという事は分かっていたので、ググり倒してAIで取り敢えず使えそうなものを単体で動かしまくった。([GoogleMLKit](https://developers.google.com/ml-kit)、[Yolo](https://www.renom.jp/ja/notebooks/tutorial/image_processing/yolo/notebook.html)、[TensorFlow](https://www.tensorflow.org/?hl=ja)等)

一ヶ月弱で作り切らないといけないという期限があった為、その期限内に実装する為時間配分を考え、技術検証をし選定するというのが非常に良い経験だった。

「**今まではレシピをググり、レシピを元に作っていた**」が
「**素材をググって集めて、レシピを自分で考え、そのレシピを元に作る**」ような感覚だった。

全く目処が立たない中最終的に辿り着いた記事が下記の二つ。
<INS>特に⓶の記事を参考に、最終的な実装方法を考えた。</INS>

 **⓵FlutterでAIを活用したゴルフのフォーム修正アプリ**
動画を解析し、ゴルフスイングの骨格を見える化、骨格から前傾姿勢の角度を見える化するもの。


https://matsumarudesu.com/flutter-golf-app-release-architecture/

**⓶AIを活用した、Google Photos内の動画検索システム**
Google Photos内の動画をVideoIntelligenceAPIで解析し、動画内のオブジェクトやテキスト等を取得し、検索ワードと一致するものを表示させる。

https://daleonai.com/building-an-ai-powered-searchable-video-archive

https://www.youtube.com/watch?v=_IeS1m8r6SY

# ⚪︎実装
## 利用する技術
- Flutter
    - [video_editor(動画編集機能パッケージ)](https://pub.dev/packages/video_editor)
- Firebase
- GoogleCloudFunctions
- GoogleVideoIntelligenceAPI

### GoogleVideoIntelligenceAPI

https://cloud.google.com/video-intelligence?hl=ja

「[Object detection and tracking](https://cloud.google.com/video-intelligence/docs/object-tracking)」を利用すると、<INS>動画内に映っている物体を検知し、さらに対象の物体の映り始め(startTimeOffset)と終わり(endTimeOffset)の時刻を取得する事ができる。</INS>
この情報をもとに自動トリミングを実装する。
![スクリーンショット 2022-10-02 12.31.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1096105/b89ed82a-e593-326d-88d4-8a25666831d7.png)
## アーキテクチャ
FlutterからFirebaseのStorageに動画をUPする。
↓
Storageへの動画のUPをトリガーにCloudFunctionsが動く
↓
Storageに保存された動画をVideoIntelligenceAPIへ渡す(CloudFunctions)
↓
解析結果のJsonFileをStorageへ保存(CloudFunctions)
↓
FlutterでJsonデータを整形。必要なデータを抽出
↓
[動画編集機能](https://pub.dev/packages/video_editor) に映り始めと終わりの時間を渡す
↓
上記の時間でトリミング👌

![Wavy'sMap 共有用.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1096105/bd86c949-6294-6af3-1e03-2f02b99f3955.png)
 
## 部分詳細
### CloudFunctions

https://cloud.google.com/video-intelligence/docs/libraries?hl=ja

FirebaseStorageへ動画が保存されたタイミングで、その動画をVideoIntelligenceAPIに渡し、返ってきたJsonFileをStorageへ保存する一連の関数です。
VideoIntelligenceAPIのクライアントライブラリを利用し、Node.jsで実装しています。
```
トリガータイプ：　Cloud Storage
Event type：選択したバケット内のファイル（最終処理 / 作成）
```
```index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const video = require('@google-cloud/video-intelligence').v1;
const fs = require('fs');
const path = require('path');
const os = require('os');
const {Storage} = require('@google-cloud/storage');

require('dotenv').config();
admin.initializeApp();

 
exports.helloGCSGeneric = async(data, context) => {
  const file = data;
  console.log(`  Bucket: ${file.bucket}`);
  console.log(`  File: ${file.name}`);
  console.log(
    `Got file ${file.name} with content type ${file.contentType}`,
);

const videoid = file.name.split('.')[0];
const jsonFile = `${videoid}.json`;
const request = {
  //解析対象の動画Path
  inputUri: `gs://${file.bucket}/${file.name}`,
  //解析結果の保存先Path
  outputUri: `解析結果JsonFileの保存先Path`,
  features: [
    //必要な情報の指定
    'LABEL_DETECTION',
    'SHOT_CHANGE_DETECTION',
  ],
};

const client = new video.VideoIntelligenceServiceClient();
console.log(`Kicking off client annotation`);
const [operation] = await client.annotateVideo(request);
console.log(`Waiting for operation to complete...`);
const [operationResult] = await operation.promise();

const annotations = operationResult.annotationResults[0];
const labels = annotations.segmentLabelAnnotations;

labels.forEach(label => {
  console.log(`Label ${label.entity.description} occurs at:`);
  label.segments.forEach(segment => {
    const time = segment.segment;
    if (time.startTimeOffset.seconds === undefined) {
      time.startTimeOffset.seconds = 0;
    }
    if (time.startTimeOffset.nanos === undefined) {
      time.startTimeOffset.nanos = 0;
    }
    if (time.endTimeOffset.seconds === undefined) {
      time.endTimeOffset.seconds = 0;
    }
    if (time.endTimeOffset.nanos === undefined) {
      time.endTimeOffset.nanos = 0;
    }
    console.log(
      `\tStart: ${time.startTimeOffset.seconds}` +
        `.${(time.startTimeOffset.nanos / 1e6).toFixed(0)}s`
    );
    console.log(
      `\tEnd: ${time.endTimeOffset.seconds}.` +
        `${(time.endTimeOffset.nanos / 1e6).toFixed(0)}s`
    );
    console.log(`\tConfidence: ${segment.confidence}`);
  });
});

console.log('operation', operation);
};

```

```package.json
{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "serve": "firebase serve --only functions",
    "shell": "firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log",
    "test": "mocha"
  },
  "dependencies": {
    "@ffmpeg-installer/ffmpeg": "^1.0.20",
    "@google-cloud/pubsub": "^2.0.0",
    "@google-cloud/video-intelligence": "^2.10.0",
    "algoliasearch": "^4.2.0",
    "array.prototype.flatmap": "^1.2.3",
    "dotenv": "^8.2.0",
    "ffmpeg-extract-frames": "^2.0.2",
    "ffmpeg-installer": "^1.0.2",
    "firebase-admin": "^8.12.1",
    "firebase-functions": "^3.6.1",
    "@firebase/storage":  "^0.9.9",
    "hashcode": "^1.0.3",
    "uuid": "^8.1.0",
    "@google-cloud/storage": "^6.4.1"
  },
  "private": true,
  "devDependencies": {
    "chai-things": "^0.2.0",
    "eslint": "^7.2.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-mocha": "^7.0.1",
    "firebase-functions-test": "^0.2.1",
    "mocha": "^7.2.0"
  },
  "engines": {
    "node": "10"
  }
}
```

### 動画編集機能に解析結果を反映

https://pub.dev/packages/video_editor

https://github.com/seel-channel/video_editor

上記リポジトリを利用し、トリミング自体はすぐに実現できたが、下記のスライダーにUIとして反映されず。
どこの値を入れればスライダーが動くのかを探し出すのが大変だった。
しかし<INS>ツヨツヨエンジニアが作った完成されたコードをいじくり回すのは非常に楽しかったし、勉強になった。</INS>
![1667201866356-aEG4T8vG0Q.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1096105/cc24f2b3-3994-64c7-4cc6-8f41b858f980.jpeg)

かなり色々な所をいじった為、変更箇所は覚えていないが、 ``controller.dart``内の下記関数に、映り始めの値(min)と映り終わりの値(max)を渡せば良い。(他にも変更必要だった気がする。)

多機能な編集機能を実装した為、面倒でしたが、動画をトリミングする機能のみのパッケージを利用すればもっと簡単に実装できると思います！
```
  void updateTrim(double min, double max) {
    _minTrim = min;
    _maxTrim = max;
    _updateTrimRange();
    notifyListeners();
  }
```

# ⚪︎終わり
現在AppStoreでリリース中なので、早くリリースしたい！リリースできたらアプリ自体の記事も書こうと思います！
自分でレシピを考える楽しさを知ってしまったので、今後も興味の赴くままに何かユニークなくだらない新しい機能を作りたいと思っています👀
Twitterでも情報発信しておりますので、ぜひフォローお願い致します!
https://mobile.twitter.com/tatsuki_kt