WebだけでScreen To Gif

この記事は[Dart Advent Calendar 2019](https://qiita.com/advent-calendar/2019/dartlang)の14日目の記事です。

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

##作ったもの
[glips](https://dennougorilla.github.io/glips/)というgif Recorderを作成しました。
Web上でScreen to gifを行えます。twitchのclip機能にインスパイアを受けて、ボタンを押すまでの10秒間を録画し、画面範囲と時間を調整してgifを作成することができます。ダウンロード不要でサーバーも介していないので、Webだけで完結しています。
おそらく、web上でScreen to gifをできるのはこれだけです:metal:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183325/dbd5c34f-a0f1-013e-c4c4-d6e0e5553f21.png)
https://dennougorilla.github.io/glips/


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

- AngularDart
- Screen Capture API 
- Javascript interoperability


なので、このあたりを中心に話していこうと思います。
## AngularDart
なぜ、AngularDartを採用したかというと、主に`Popup dialog`と`Slider component`を使いたかったからです。`Popup dialog`はClipEditorの部分で、`Slider component`は時間範囲の指定部分で、範囲選択をできるsliderがあったからです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183325/4b243541-9c40-110e-8629-7cf550987844.png)
実用性と開発速度重視だったので、AngularDartのおかげでかなり楽に開発できました。AngularDartについては、去年のDart Advent Calendarにとてもいい[記事](https://ntaoo.hatenablog.com/entry/2018/12/15/173713)があります。

## Screen Capture API
Web APIには[Screen Capture API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API)といって、既存のMedia Capture and APIに追加された画面または画面の一部を選択し、ストリームに記録する機能があります。これを使い、画面情報を録画します。また、Dartは[dart:html](https://api.dartlang.org/stable/2.7.0/dart-html/dart-html-library.html)という標準PackageからWeb APIを使うことができます。

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

え？って感じですが、使う人がいないのかまだ実装されていません。同じAPI郡の[getUserMedia](https://api.dartlang.org/stable/2.7.0/dart-html/Navigator/getUserMedia.html)は実装されています。仕方がないので、`getUserMedia`の実装を見ながら、自分で使えるようにしてみました。ここで使うのが、[dart:js](https://pub.dev/documentation/js/latest/)でjavascript interoperabilityです。

```dart: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](https://github.com/dart-lang/sdk/issues/27315#issuecomment-374927185)を見ながら実装しました。正直、自分でもよくわかっていないんですけど、まあ動くので良しとしています。

```dart
final stream = await getDisplayMedia();
    final video = VideoElement()
      ..id = 'video'
      ..autoplay = true
      ..srcObject = stream;
```
`getMediaDevice`と同じように一度VideoElementに落とし込んでから、扱っています。

```dart: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を保存しています。

```dart: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](https://api.dartlang.org/stable/2.7.0/dart-collection/Queue-class.html)というコレクションを使用しています。これを使うことで、両端の操作がとても楽になるので、キューを使用して300/30で10秒間のClipを常に保持しています。

##encode gif
Dartには、[image](https://pub.dev/packages/image)と[gifencoder](https://pub.dev/packages/gifencoder)の2つのgifエンコーダーがありましたが、どちらも不採用となってしまいました。理由としては、まず[gif encodoer](https://pub.dev/packages/gifencoder)は画像の入力を255色に量子化（減色処理）をしないと駄目で、この減色処理がとても面倒でした。ちなみにgifは255色で構成していて、これによってデータサイズを下げて圧縮しています。この減色処理アルゴリズムの良し悪しによってgifの綺麗さが変わります。詳しくは[wiki]
(https://ja.wikipedia.org/wiki/%E8%89%B2%E3%81%AE%E9%87%8F%E5%AD%90%E5%8C%96)で
![Rosa_Gold_Glow_2_small_noblue_color_space.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183325/c7181270-2dc4-e445-1cc7-379c9038edfa.png)
そして、この減色処理がとても重く、[image](https://pub.dev/packages/gifencoder)の方は、減色処理も実装されていましたが、重すぎて使い物になりませんでした。この解決策が、

[gif.js](https://github.com/jnordberg/gif.js/)

でした。こちらはjsライブラリになっているんですが、[Web Worker](https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API)の数をオプションで指定できます。このライブラリを先程のJavascript interoperabilityによってラップして使えるようにします。こちらは、一応[Package](https://github.com/dennougorilla/gif_dart)にしたので、よかったら参考にしてみてください。
##Javascript interoperability
ここで、Dartでラップしたgif.jsをコールバック関数を渡して呼び出すときに、Javascript interoperabilityの[issue](https://github.com/dart-lang/sdk/issues/33134)に引っかかりました。

```js:finish.js
gif.on('finished', function(blob) {
  window.open(URL.createObjectURL(blob));
});
```
↑こちらが本家のexampleで、引数はblobの一個だけですが

```dart: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には引数についてなどのドキュメントはなく、たまたま２個で上手くいったという感じになりました。こちらもntaooさんが詳細な[記事](https://ntaoo.hatenablog.com/entry/2019/06/04/170455)を書いてくれています🙏
##最後に
ここまで読んで頂きありがとうございます！拙いコードですが[GitHub](https://github.com/dennougorilla/glips)に上がっているのでよかったら確認してみてください。実は、gif encoderの部分でdartのpackageを使うことに固執してしまって、かなり時間を使ってしまいました。せっかく、Javascript interoperabilityを使用して豊富なjsライブラリを利用できるので、もう少し柔軟に検討できるようになりたいと思います。今後、Webやflutterだけでなく、cliツールやサーバーサイドなどでもDartを活用していこうと思っているので、少しでも知見を共有できたらと思います。
![image](https://media1.tenor.com/images/972424767943ed34a19f6ff2a9cbe976/tenor.gif?itemid=14192312)