Flutterで動画編集ができるパッケージ、Tapiocaを開発しているのですが、開発している中で知見がかなり多くあったので共有させていただきます!
Tapiocaでは↑上のような動画編集が↓下のようなコードでシンプルに書けます。
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
pub.dev(公式パッケージサイト)
また、Tapiocaのコミュニティを作りました。
興味ある方はぜひDiscordサーバーに参加してください。
質問大歓迎です。
名前の由来
開発開始当時、タピオカが流行ってていつも飲んでたのでタピオカにしました。
あと、RustのWebフレームワークのRocketで、Rocketに掛けてlaunchという関数があってそういうのにちょっと憧れてました。
コンセプト
タピオカ(動画編集の要素)をコップ(動画)に入れて、吸うと動画が編集されるというイメージです。
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の機能
カラーフィルターをかける
ピンクのフィルターをかける
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");
});
青のフィルターをかける
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");
});
文字をつける
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");
});
画像をつける
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");
});
実装方法
AndroidとiOSにどちらも対応せさなければいけないので、当然それぞれのネイティブのコードを書く必要があります。
どちらも書くのは少し大変ですが、私のパッケージの場合はUIを書く必要がなく処理の部分を書けばいいだけなので、どうやって動画編集の処理を書くかだけ調べて実装しました。
- MethodChannelでFlutterとネイティブをつなげる
- Android独自のコードを書く(MediaCodec APIが使われているMp4Composer-androidを使用)
- iOS独自のコードを書く(AVFoundationを使用)
MethodChannelでFlutterとネイティブをつなげる
MethodChannelを用いて、Android独自のコード、iOS独自のコードをDartから使えるようにします。
公式の記事ではMethodChannelの使い方はわかるものの、そこまで詳しく触れられていません。
なので、ドキュメントを読んだり、GithubでMethodChannelのコードを検索するのが一番参考になります。
Dartのコード
https://github.com/anharu2394/tapioca/blob/master/lib/src/video_editor.dart
iOSとAndroidのコードで定義している、writeVideofile
というメソッドを呼び出しています。
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
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
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で書いてます。
// 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を試せます。
ぜひ、試してみてください
動画処理の進捗を表示する
現在、動画処理の進捗を表示する機能を実装中です。
そのときに使うEventChannelというのが面白かったので紹介します。
Androidでは実装終了し、以下のような感じで使用できる予定です。(🚨まだリリースしてません)
TapiocaでFlutterのEventChanelを使って
— あんはる@アプリ甲子園 準優勝 (@_anharu) February 27, 2022
ネイティブのAndroidと通信して動画処理の進捗のパーセントを表示することに成功。
まだiOSやってない#tapioca #flutter pic.twitter.com/Sqgx8FmFc7
EventChannel
ネイティブからFlutterに連続的だったり即座に通知したいことを送ることができます。この場合では、進捗のパーセントをEventChannelで送っています。
EventChannelを定義する。
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.")
}
})
}
// 省略
動画編集中にイベントを送る
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に組み込むことができます。
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サーバーに参加してください。
質問大歓迎です。
終わりに
Tapiocaの開発はAndroid,iOSそれぞれ独自のコードを書かなければならないため、労力がかかります。
しかし、FlutterでAndroid,iOSどちらも動き、他のパッケージにはない機能を完成させるという達成感は大きいです。
これからも、使っていただける方々のために頑張りたいです。