✂️

【Flutter】動画の自動トリミング機能を実装した話

2022/11/06に公開

⚪︎はじめに

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

⚪︎何をしたかったのか

BMXの練習動画を投稿する際に行う、不要な箇所を取り除くトリミング作業が面倒だった為、「動画をぶち込んだら勝手にトリミングしてくれたら良いなあ〜」という感じ
行ってみよっ🤪☝️
Qiita.gif

⚪︎構想

アプリ自体は1ヶ月弱で制作し、自動トリミング機能単体で2週間程かかりました。

記事がねえ

動画の自動トリミング機能実装に関する記事がほとんどありませんでした。
プログラミングをしっかり学び始めて約半年の僕は、既に記事のある実装方法が分かっている物しか作った事がありませんでした。
自分で考えるしかない。。。

実装方法を考える

AIを使えば出来そうだという事は分かっていたので、ググり倒してAIで取り敢えず使えそうなものを単体で動かしまくった。(GoogleMLKitYoloTensorFlow等)

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

⓵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://youtu.be/_IeS1m8r6SY

⚪︎実装

利用する技術

GoogleVideoIntelligenceAPI

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

Object detection and tracking」を利用すると、動画内に映っている物体を検知し、さらに対象の物体の映り始め(startTimeOffset)と終わり(endTimeOffset)の時刻を取得する事ができる。
この情報をもとに自動トリミングを実装する。
【返ってくるJsonデータの一部】
スクリーンショット 2022-10-02 12.31.43.png

アーキテクチャ

FlutterからFirebaseのStorageに動画をUPする。

Storageへの動画のUPをトリガーにCloudFunctionsが動く

Storageに保存された動画をVideoIntelligenceAPIへ渡す(CloudFunctions)

解析結果のJsonFileをStorageへ保存(CloudFunctions)

FlutterでJsonデータを整形。必要なデータを抽出

動画編集機能 に映り始めと終わりの時間を渡す

上記の時間でトリミング👌

Wavy'sMap 共有用.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",
    "array.prototype.flatmap": "^1.2.3",
    "dotenv": "^8.2.0",
    "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"
  }
}

データの整形、抽出(Flutter)

欲しいデータを取り出すのも結構苦労した。
細かい解説は省くが、こーゆー時にpaizaをやっていて良かったと思った。
もっとスマートなやり方は絶対にあると思うが、なんとかデータを操作出来たのはpaizaをやっていたおかげだった。

  void get(WidgetRef ref) async {
    try{
      Map<String, dynamic> result = {};
      await db.collection("messages").get().then((event) {
        for (var doc in event.docs) {
          result = doc.data();
          firestoreDataId = doc.id;
        }
      //firestore上の解析結果削除
      delete();

      //valueのみを抽出
      var value = result['original'];
      //valueをMAPとして使えるようデコード
      var valueMap = jsonDecode(value);
      
      //該当データを入れる変数
      var targetdata;

      //解析データの中から該当データを探す
      //初回検索【bmx bike】
      for(var i = 0; i < valueMap['shotLabelAnnotations'].length; i++){
        if(valueMap['shotLabelAnnotations'][i]['entity']['description'] == 'bmx bike'){
          targetdata = valueMap['shotLabelAnnotations'][i];
          // print(targetdata);
        } 
      }
      //2回目検索 1回目の検索でヒットしなかった場合【bicycle】に条件を変更し検索
      if(targetdata == null){
        for(var i = 0; i < valueMap['shotLabelAnnotations'].length; i++){
          if(valueMap['shotLabelAnnotations'][i]['entity']['description'] == 'bicycle'){
            targetdata = valueMap['shotLabelAnnotations'][i];
          } 
        }
      }
      //3回目 解析結果に検索条件が無かった場合は、メインページへ戻す
      if(targetdata == null){
        //解析不能時Snackbar
        Get.snackbar(
          '動画自動解析',
          '解析不能。動画投稿ページから投稿してください。Sorry...😢',
          icon:const Text(
            "😢",
            style: TextStyle(
              fontSize: 40
            ),
            ),
          duration: const Duration(seconds: 4),
        );
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => HomeScreen(),
          ),
        );
      }
      
      //Start時刻定義(double型)
      final startSeconds = double.parse(targetdata['segments'][0]['segment']['startTimeOffset']['seconds']);
      final startNanos = targetdata['segments'][0]['segment']['startTimeOffset']['nanos'] * 0.000000001;
      final startTime = startSeconds + startNanos;
      print(startTime);
      // × 0.000000001
      //End時刻定義(double型)
      final endSeconds = double.parse(targetdata['segments'][0]['segment']['endTimeOffset']['seconds']);
      final endNanos = targetdata['segments'][0]['segment']['endTimeOffset']['nanos'] * 0.000000001;
      final endTime = endSeconds + endNanos;
      print(endTime);

      //プロバイダーにstartTimeとendTimeを渡す
      ref.watch(StartTrimStateProvider.state).state = startTime;
      ref.watch(EndTrimStateProvider.state).state = endTime;
      if(endTime !=0.0){
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => VideoEditor_app(),
          ),
        );
      }
    });
    } catch (e){
      print('Error : $e');
    }
  }

動画編集機能に解析結果を反映(Flutter)

https://pub.dev/packages/video_editor

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

上記リポジトリを利用し、トリミング自体はすぐに実現できたが、下記のスライダーにUIとして反映されず。
どこの値を入れればスライダーが動くのかを探し出すのが大変だった。
しかしツヨツヨエンジニアが作った完成されたコードをいじくり回すのは非常に楽しかったし、勉強になった。
1667201866356-aEG4T8vG0Q.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

Discussion