WebだけでScreen To Gif


この記事はDart Advent Calendar 2019の14日目の記事です。

はじめに

こんにちは、Dart大好きdennougorillaです。最近はDartよりもFlutterのほうが先走っていて、Flutter Advent Calendar 2019はその2まであるにも関わらず、Dart Advent Calendar 2019はスカスカです・・・自分もFlutterからDartをはじめましたが、最近はDart自体に興味を惹かれています。Dartは大統一言語になる可能性を大いに秘めている言語だと個人的には思っていて、この記事で少しでもDartコミュニティが盛り上げられればと思いエントリーしました。

作ったもの

glipsというgif Recorderを作成しました。
Web上でScreen to gifを行えます。twitchのclip機能にインスパイアを受けて、ボタンを押すまでの10秒間を録画し、画面範囲と時間を調整してgifを作成することができます。ダウンロード不要でサーバーも介していないので、Webだけで完結しています。
おそらく、web上でScreen to gifをできるのはこれだけです

https://dennougorilla.github.io/glips/

Dart

実はgifginという同じようなものをJavaScriptで作っていたので、それをDartで書き直すと言った感じになりました。以前との違いは、clip機能で、以前の方だとstartとstop方式だったので、時間範囲をミスった時の撮り直しが多く、clip方式にして後から時間や画面範囲を選択できるようにしました。Dartの洗礼された言語仕様や強力なエコシステムによって、とても開発しやすかったです。主に使ったものとして、

  • AngularDart
  • Screen Capture API
  • Javascript interoperability

なので、このあたりを中心に話していこうと思います。

AngularDart

なぜ、AngularDartを採用したかというと、主にPopup dialogSlider componentを使いたかったからです。Popup dialogはClipEditorの部分で、Slider componentは時間範囲の指定部分で、範囲選択をできるsliderがあったからです。

実用性と開発速度重視だったので、AngularDartのおかげでかなり楽に開発できました。AngularDartについては、去年のDart Advent Calendarにとてもいい記事があります。

Screen Capture API

Web APIにはScreen Capture APIといって、既存のMedia Capture and APIに追加された画面または画面の一部を選択し、ストリームに記録する機能があります。これを使い、画面情報を録画します。また、Dartはdart:htmlという標準PackageからWeb APIを使うことができます。

ちなみに、dart:htmlにScreen Capture APIは実装されていません

え?って感じですが、使う人がいないのかまだ実装されていません。同じAPI郡のgetUserMediaは実装されています。仕方がないので、getUserMediaの実装を見ながら、自分で使えるようにしてみました。ここで使うのが、dart:jsでjavascript interoperabilityです。

get_display_media.dart
@JS()
library main;

import 'dart:async';
import 'dart:html';
import 'dart:html_common';
import 'package:js/js.dart';

Future<MediaStream> getDisplayMedia([dynamic constraints]) {
  final completer = Completer<MediaStream>();
  _getDisplayMedia(convertDartToNative_SerializedScriptValue(constraints)).then(
      allowInterop(completer.complete), allowInterop(completer.completeError));
  return completer.future;
}

@JS()
class Promise<T> {
  external Promise(void executor(void resolve(T result), Function reject));
  external Promise then(void Function(T) onFulfilled, [Function onRejected]);
}

@JS('navigator.mediaDevices.getDisplayMedia')
external Promise<MediaStream> _getDisplayMedia([dynamic constraints]);

getDisplayMediaはPromiseなので実装が複雑になっています。このissueを見ながら実装しました。正直、自分でもよくわかっていないんですけど、まあ動くので良しとしています。

final stream = await getDisplayMedia();
    final video = VideoElement()
      ..id = 'video'
      ..autoplay = true
      ..srcObject = stream;

getMediaDeviceと同じように一度VideoElementに落とし込んでから、扱っています。

draw_video.dart
    Timer.periodic(const Duration(milliseconds: 33),
        (Timer t) => drawVideo(screenCanvas, video, clip));

  void drawVideo(CanvasElement canvas, VideoElement video, Clip clip) {
    final screenOption = dumpOptionsInfo(video);
    canvas
      ..width = screenOption['width'] ~/ 2
      ..height = screenOption['height'] ~/ 2
      ..context2D.scale(0.5, 0.5)
      ..context2D.drawImage(video, 0, 0);

    clip.setImage(
        canvas.context2D.getImageData(0, 0, canvas.width, canvas.height));
  }

そして、Timer.periodicを使い、だいたい30fpsぐらいでcanvas elementに書き込んで表示し、自前のClip ClassにimageDataを保存しています。

clip.dart
import 'dart:collection';
import 'dart:html';
class Clip {
  Queue<ImageData> frameQueue = Queue<ImageData>();
  int maxLength = 300;

  void setImage(ImageData image) {
    if (frameQueue.length < maxLength) {
      frameQueue.addLast(image);
    } else {
      frameQueue
      ..removeFirst()
      ..addLast(image);
    }
  }
}

ここで、Queueというコレクションを使用しています。これを使うことで、両端の操作がとても楽になるので、キューを使用して300/30で10秒間のClipを常に保持しています。

encode gif

Dartには、imagegifencoderの2つのgifエンコーダーがありましたが、どちらも不採用となってしまいました。理由としては、まずgif encodoerは画像の入力を255色に量子化(減色処理)をしないと駄目で、この減色処理がとても面倒でした。ちなみにgifは255色で構成していて、これによってデータサイズを下げて圧縮しています。この減色処理アルゴリズムの良し悪しによってgifの綺麗さが変わります。詳しくはwiki

そして、この減色処理がとても重く、imageの方は、減色処理も実装されていましたが、重すぎて使い物になりませんでした。この解決策が、

gif.js

でした。こちらはjsライブラリになっているんですが、Web Workerの数をオプションで指定できます。このライブラリを先程のJavascript interoperabilityによってラップして使えるようにします。こちらは、一応Packageにしたので、よかったら参考にしてみてください。

Javascript interoperability

ここで、Dartでラップしたgif.jsをコールバック関数を渡して呼び出すときに、Javascript interoperabilityのissueに引っかかりました。

finish.js
gif.on('finished', function(blob) {
  window.open(URL.createObjectURL(blob));
});

↑こちらが本家のexampleで、引数はblobの一個だけですが

finish.dart
  gif.on('finished', allowInterop((blob, tmp) {
    window.open(Url.createObjectUrl(blob), 'gif');
  }));

↑このように、dartはallowInteropを使用して、Dartで記述したコールバック関数を渡しますが、このときに、呼び出し元の引数の数が合っていないとErrorになってしまいます。そのため引数の数を合わせるためにtmpもコールバック関数に持たせています。ちなみにこちらは、開発時コンパイルに使うdartdevcではパスしてしまい、ビルドコンパイルに使うdart2jsではエラーとして弾かれる闇仕様になっているみたいです。自分の場合は、Web Workerを使用していて、たまたまwebdev run -rとしてdart2jsの方でコンパイルしていて、すぐに気づけましたが、gif.jsには引数についてなどのドキュメントはなく、たまたま2個で上手くいったという感じになりました。こちらもntaooさんが詳細な記事を書いてくれています🙏

最後に

ここまで読んで頂きありがとうございます!拙いコードですがGitHubに上がっているのでよかったら確認してみてください。実は、gif encoderの部分でdartのpackageを使うことに固執してしまって、かなり時間を使ってしまいました。せっかく、Javascript interoperabilityを使用して豊富なjsライブラリを利用できるので、もう少し柔軟に検討できるようになりたいと思います。今後、Webやflutterだけでなく、cliツールやサーバーサイドなどでもDartを活用していこうと思っているので、少しでも知見を共有できたらと思います。