Flutterで動画編集ができるパッケージ、Tapiocaを作った話

Flutterで動画編集ができるパッケージ、Tapiocaを開発しているのですが、開発している中で知見がかなり多くあったので共有させていただきます！
![1646216090296result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188304/ecac2b6c-6fa7-97d5-a669-5890d906b2c1.gif)

Tapiocaでは↑上のような動画編集が↓下のようなコードでシンプルに書けます。
```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.filter(Filters.pink),
    TapiocaBall.imageOverlay(imageBitmap, 300, 5),
    TapiocaBall.textOverlay("Hello world!", 100, 100, 100, Color(0xffffc0cb)),
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});
```

Github

https://github.com/anharu2394/tapioca

pub.dev(公式パッケージサイト)

https://pub.dev/packages/tapioca

また、Tapiocaのコミュニティを作りました。
興味ある方はぜひDiscordサーバーに参加してください。
質問大歓迎です。
[![Discord Banner 4](https://discordapp.com/api/guilds/947061556045283348/widget.png?style=banner4)](https://discord.gg/3Be9nP5cjb)

# 名前の由来
開発開始当時、タピオカが流行ってていつも飲んでたのでタピオカにしました。
あと、[RustのWebフレームワークのRocket](https://github.com/SergioBenitez/Rocket)で、Rocketに掛けてlaunchという関数があってそういうのにちょっと憧れてました。

# コンセプト
タピオカ（動画編集の要素）をコップ（動画）に入れて、吸うと動画が編集されるというイメージです。

```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.filter(Filters.pink),
    TapiocaBall.imageOverlay(imageBitmap, 300, 300),
    TapiocaBall.textOverlay("text",100,10,100,Color(0xffffc0cb)),
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});
```

# なぜ作ろうと思ったか
当時、FlutterでTiktokのようなアプリを開発していたときに、動画撮影し編集する画面の開発に取り掛かりました。

そのときFlutterで動画編集をする方法はFFmpegしかありませんでした。しかし、FFmpegのライセンスはGPLなので、商用利用するとなるとライセンス料がかかります。

そこで、Android、iOS独自のAPIを用いて動画処理を行いたいと考えました。そうすれば、FFmpegに依存しない形で動画編集を利用できます。


# Tapiocaが目指すもの

Instagramの編集機能をTapiocaを用いて実装することを目指しています。

# Tapiocaの特徴

- シンプルに動画処理がかける
- FFmpegに依存していない

# Tapiocaを公開してから
Tapiocaを公開すると、pub.dev の`popularity`が上がったり、英語の記事にいいねが付いたり、メールでTapiocaに関する質問が多数届くなどかなり反響がありました。
Tapioca自体の機能は少ないですが、このような反響を頂いたのでTapiocaのシンプルに動画処理ができるという強みが伝わったと考えています。これからもTapiocaの改善をしていきたいと思いました。

# Tapiocaの機能

## カラーフィルターをかける

### ピンクのフィルターをかける
```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.filter(Filters.pink)
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});
```
![](https://miro.medium.com/max/1024/1*icvF26W1koWzb6uBUqNbkg.gif)

### 青のフィルターをかける

```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.filter(Filters.blue)
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});
```
![](https://miro.medium.com/max/1024/1*P7XpkmE8fjj-ZrbhAJuSYw.gif)
## 文字をつける

```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.textOverlay("text",100,10,500,Color(0xffffc0cb)),
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});
```
![](https://miro.medium.com/max/1024/1*PHOHVv1B9jY-lqEkFY5oJQ.gif)
## 画像をつける

```dart
import 'package:tapioca/tapioca.dart';
import 'package:path_provider/path_provider.dart';

final tapiocaBalls = [
    TapiocaBall.imageOverlay(imageBitmap, 300,0),
];
var tempDir = await getTemporaryDirectory();
final path = '${tempDir.path}/result.mp4';
final cup = Cup(Content(videoPath), tapiocaBalls);
cup.suckUp(path).then((_) {
  print("finish processing");
});

```
![](https://miro.medium.com/max/1024/1*VtorE6bN4ErgscTuqjW36A.gif)

# 実装方法

AndroidとiOSにどちらも対応せさなければいけないので、当然それぞれのネイティブのコードを書く必要があります。
どちらも書くのは少し大変ですが、私のパッケージの場合はUIを書く必要がなく処理の部分を書けばいいだけなので、どうやって動画編集の処理を書くかだけ調べて実装しました。

1. MethodChannelでFlutterとネイティブをつなげる
2. Android独自のコードを書く（MediaCodec APIが使われているMp4Composer-androidを使用）
3. iOS独自のコードを書く（AVFoundationを使用）

## MethodChannelでFlutterとネイティブをつなげる
MethodChannelを用いて、Android独自のコード、iOS独自のコードをDartから使えるようにします。

https://docs.flutter.dev/development/platform-integration/platform-channels

公式の記事ではMethodChannelの使い方はわかるものの、そこまで詳しく触れられていません。

なので、ドキュメントを読んだり、GithubでMethodChannelのコードを検索するのが一番参考になります。

https://api.flutter.dev/flutter/services/MethodChannel-class.html


Dartのコード
https://github.com/anharu2394/tapioca/blob/master/lib/src/video_editor.dart

iOSとAndroidのコードで定義している、`writeVideofile`というメソッドを呼び出しています。
```dart
import 'dart:async';
import 'package:flutter/services.dart';

class VideoEditor {
  static const MethodChannel _channel =
  const MethodChannel('video_editor');

  static Future<String> get platformVersion async {
    final String version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }

  static Future writeVideofile(String srcFilePath, String destFilePath, Map<String,Map<String, dynamic>> processing) async {
    await _channel.invokeMethod('writeVideofile',<String, dynamic> { 'srcFilePath': srcFilePath, 'destFilePath': destFilePath, 'processing': processing });
  }
}
```

iOSのコード
ここで、`writeVideofile`が呼び出されたときどういう処理をするのかを書いています。
https://github.com/anharu2394/tapioca/blob/master/ios/Classes/SwiftVideoEditorPlugin.swift


```swift
import Flutter
import UIKit

public class SwiftVideoEditorPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "video_editor", binaryMessenger: registrar.messenger())
    let instance = SwiftVideoEditorPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "writeVideofile":
        let video = VideoGeneratorService()
        guard let args = call.arguments as? [String: Any] else {
          result(FlutterError(code: "arguments_not_found",
                            message: "the arguments is not found.",
                            details: nil))
          return
        }
        guard let srcName = args["srcFilePath"] as? String else {
                result(FlutterError(code: "src_file_path_not_found",
                                  message: "the src file path sr is not found.",
                                  details: nil))
                return
        }
        guard let destName = args["destFilePath"] as? String else {
                result(FlutterError(code: "dest_file_path_not_found",
                                  message: "the dest file path is not found.",
                                  details: nil))
                return
        }
        guard let processing = args["processing"] as?  [String: [String: Any]] else {
                result(FlutterError(code: "processing_data_not_found",
                                  message: "the processing is not found.",
                                  details: nil))
                return
        }
        video.writeVideofile(srcPath: srcName, destPath: destName, processing: processing,result: result)
    default:
        result("iOS d" + UIDevice.current.systemVersion)
    }
  }
}
```

Androidのコード
同じく、`writeVideofile`が呼ばれたときの処理を定義してます。
https://github.com/anharu2394/tapioca/blob/master/android/src/main/kotlin/me/anharu/video_editor/VideoEditorPlugin.kt

```kotlin
package me.anharu.video_editor

import android.Manifest
import android.app.Application
import android.app.Activity
// ~~import省略~~

/** VideoEditorPlugin */
public class VideoEditorPlugin : FlutterPlugin, MethodCallHandler, PluginRegistry.RequestPermissionsResultListener, ActivityAware {
    var activity: Activity? = null
    private var methodChannel: MethodChannel? = null
    private val myPermissionCode = 34264

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        onAttachedToEngine(flutterPluginBinding.binaryMessenger)
    }

    fun onAttachedToEngine(messenger: BinaryMessenger) {
        methodChannel = MethodChannel(messenger, "video_editor")
        methodChannel?.setMethodCallHandler(this)
    }


    // This static function is optional and equivalent to onAttachedToEngine. It supports the old
    // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting
    // plugin registration via this function while apps migrate to use the new Android APIs
    // post-flutter-1.12 via https://flutter.dev/go/android-project-migration.
    //
    // It is encouraged to share logic between onAttachedToEngine and registerWith to keep
    // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called
    // depending on the user's project. onAttachedToEngine or registerWith must both be defined
    // in the same class.
    companion object {
        @JvmStatic
        fun registerWith(registrar: Registrar) {
            val instance = VideoEditorPlugin()
            instance.onAttachedToEngine(registrar.messenger())
        }
    }

    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        if (call.method == "getPlatformVersion") {
            result.success("Android ${android.os.Build.VERSION.RELEASE}")
        } else if (call.method == "writeVideofile") {

            var getActivity = activity ?: return
            checkPermission(getActivity)

            val srcFilePath: String = call.argument("srcFilePath") ?: run {
                result.error("src_file_path_not_found", "the src file path is not found.", null)
                return
            }
            val destFilePath: String = call.argument("destFilePath") ?: run {
                result.error("dest_file_path_not_found", "the dest file path is not found.", null)
                return
            }
            val processing: HashMap<String, HashMap<String, Any>> = call.argument("processing")
                    ?: run {
                        result.error("processing_data_not_found", "the processing is not found.", null)
                        return
                    }
            val generator = VideoGeneratorService(Mp4Composer(srcFilePath, destFilePath))
            generator.writeVideofile(processing, result, getActivity)
        } else {
            result.notImplemented()
        }
    }

// 以下省略  
```
## Android独自のコードを書く
Android MediaCodec APIが使われているMp4Composer-androidというパッケージを用いて動画処理を実装しました。
Kotlinで書いてます。

https://github.com/MasayukiSuda/Mp4Composer-android

https://github.com/anharu2394/tapioca/blob/master/android/src/main/kotlin/me/anharu/video_editor/VideoGeneratorService.kt で処理を書いてます
```kotlin
// import省略
interface VideoGeneratorServiceInterface {
    fun writeVideofile(processing: HashMap<String,HashMap<String,Any>>, result: Result, activity: Activity);
}

class VideoGeneratorService(
        private val composer: Mp4Composer
) : VideoGeneratorServiceInterface {
    override fun writeVideofile(processing: HashMap<String,HashMap<String,Any>>, result: Result, activity: Activity ) {
        val filters: MutableList<GlFilter> = mutableListOf()
        try {
            processing.forEach { (k, v) ->
                when (k) {
                    "Filter" -> {
                        val passFilter = Filter(v)
                        val filter = GlColorBlendFilter(passFilter)
                        filters.add(filter)
                    }
                    "TextOverlay" -> {
                        val textOverlay = TextOverlay(v)
                        filters.add(GlTextOverlayFilter(textOverlay))
                    }
                    "ImageOverlay" -> {
                        val imageOverlay = ImageOverlay(v)
                        filters.add(GlImageOverlayFilter(imageOverlay))
                    }
                }
            }
        } catch (e: Exception){
            println(e)
            activity.runOnUiThread(Runnable {
                result.error("processing_data_invalid", "Processing data is invalid.", null)
            })
        }
        composer.filter(GlFilterGroup( filters))
                .listener(object : Mp4Composer.Listener {
                    override fun onProgress(progress: Double) {
                        println("onProgress = " + progress)
                    }

                    override fun onCompleted() {
                        activity.runOnUiThread(Runnable {
                            result.success(null)
                        })
                    }

                    override  fun onCanceled() {
                        activity.runOnUiThread(Runnable {
                            result.error("video_processing_canceled", "Video processing is canceled.", null)
                        })
                    }

                    override fun onFailed(exception: Exception) {
                        println(exception);
                        activity.runOnUiThread(Runnable {
                            result.error("video_processing_failed", "video processing is failed.", null)
                        })
                    }
                }).start()
    }
}
```

## iOS独自のコードを書く
AVFoundationを用いて実装しました。
コードが長いので割愛しますが、
https://github.com/anharu2394/tapioca/blob/master/ios/Classes/VideoGeneratorService.swift で実装してます。
`AVVideoComposition`で動画にフィルターしていきます。

# Exampleを実行
`example`ディレクトリ以下をflutterでビルドすれば簡単にtapiocaを試せます。
ぜひ、試してみてください

https://github.com/anharu2394/tapioca/tree/master/example

# 動画処理の進捗を表示する
現在、動画処理の進捗を表示する機能を実装中です。
そのときに使うEventChannelというのが面白かったので紹介します。
Androidでは実装終了し、以下のような感じで使用できる予定です。（🚨まだリリースしてません）
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">TapiocaでFlutterのEventChanelを使って<br>ネイティブのAndroidと通信して動画処理の進捗のパーセントを表示することに成功。<br>まだiOSやってない<a href="https://twitter.com/hashtag/tapioca?src=hash&amp;ref_src=twsrc%5Etfw">#tapioca</a> <a href="https://twitter.com/hashtag/flutter?src=hash&amp;ref_src=twsrc%5Etfw">#flutter</a> <a href="https://t.co/Sqgx8FmFc7">pic.twitter.com/Sqgx8FmFc7</a></p>&mdash; あんはる@アプリ甲子園 準優勝 (@_anharu) <a href="https://twitter.com/_anharu/status/1497933268599328769?ref_src=twsrc%5Etfw">February 27, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## EventChannel
ネイティブからFlutterに連続的だったり即座に通知したいことを送ることができます。この場合では、進捗のパーセントをEventChannelで送っています。

### EventChannelを定義する。
```kotlin
public class VideoEditorPlugin : FlutterPlugin, MethodCallHandler, PluginRegistry.RequestPermissionsResultListener, ActivityAware {
    var activity: Activity? = null
    private var methodChannel: MethodChannel? = null
    private var eventChannel: EventChannel? = null
    private val myPermissionCode = 34264
    private var eventSink : EventChannel.EventSink? = null

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        onAttachedToEngine(flutterPluginBinding.binaryMessenger)
    }

    fun onAttachedToEngine(messenger: BinaryMessenger) {
        methodChannel = MethodChannel(messenger, "video_editor")
        eventChannel = EventChannel(messenger, "video_editor_progress")
        methodChannel?.setMethodCallHandler(this)
        eventChannel?.setStreamHandler(object : EventChannel.StreamHandler {
            override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
                eventSink = events

            }
            override fun onCancel(arguments: Any?) {
                println("Event Channel is canceled.")
            }
        })
    }
// 省略
```

### 動画編集中にイベントを送る
```kotlin
        composer.filter(GlFilterGroup( filters))
                .videoFormatMimeType(VideoFormatMimeType.HEVC)
                .listener(object : Mp4Composer.Listener {
                    override fun onProgress(progress: Double) {
                        println("onProgress = " + progress)
                        activity.runOnUiThread(Runnable {
                            eventSink.success(progress)
                        })
                    }
```
### UIで読み込む
ここで`listen`してステートを変更すればUIに組み込むことができます。
```kotlin
  void _enableEventReceiver() {
    _streamSubscription = _channel.receiveBroadcastStream().listen(
            (dynamic event) {
          setState((){
            processPercentage = (event.toDouble()*100).round();
          });
        },
        onError: (dynamic error) {
          print('Received error: ${error.message}');
        },
        cancelOnError: true);
  }

```

# コミュニティ
Discordサーバーを作りました。
## 目的
- TapiocaユーザーやTapiocaに興味ある人の交流
- Tapiocaの貢献の手助け
- Tapiocaの改善

興味ある方はぜひDiscordサーバーに参加してください。
質問大歓迎です。
[![Discord Banner 4](https://discordapp.com/api/guilds/947061556045283348/widget.png?style=banner4)](https://discord.gg/3Be9nP5cjb)

# 終わりに
Tapiocaの開発はAndroid,iOSそれぞれ独自のコードを書かなければならないため、労力がかかります。
しかし、FlutterでAndroid,iOSどちらも動き、他のパッケージにはない機能を完成させるという達成感は大きいです。
これからも、使っていただける方々のために頑張りたいです。