201
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

VueとSVGでGIFアニメ生成アプリ作ったのでソース全公開&解説

今日は週末に勢いでGIFアニメ生成アプリを作ったので開発の流れ&技術的なポイントを共有します。ソースコードも全て公開しているので、ご自由に流用して遊んでみてください。

作ったもの

fluflunekochan.gif

アプリ: https://fulful.web.app/
ソースコード : https://github.com/yuneco/fulful

珍しく、ちょいバズりました(※当社比)

この記事で書くこと

  • ちょっとした個人開発アプリを短期間でリリースする流れ&ポイント
  • 技術解説:SVGフィルタの作り方
  • 技術解説:ブラウザでGIFアニメを生成するアプリの作り方

なお、このアプリはもともとお仕事で↓の記事を書くための調べ物をしていて脱線した結果です。真面目な技術解説はこちらの記事もご参照くださいませ:
君は使い分けられるか?CSS/SVG/Canvasのビジュアル表現でできること・できないこと

開発の流れ・短期個人開発のコツ

今回のアプリは週末のたぶん15時間くらいで作ってます。ざっくりどんな流れで作ったのか、前半で紹介します。(ポエムに的な話に興味のない方は前半分飛ばしてください)

コアになるコンセプトを絞る

今回のアプリ「フルフルネコチャン」でできることは「画像をふるふるさせること」だけです。
それだけ。

作りたいものの妄想を膨らませていると色々アイデア湧いてきますよね?

1つだけ残して、全部埋めましょう。

初めからマネタイズが見えているようなサービスならまだしも、個人が趣味で作るもので色々詰め込むと99割挫折してお蔵入りになります。

「趣味の個人開発はコアのアイデア1個で勝負」←これ大切です:ok_woman:

今回捨てた機能

ご参考までに、今回捨てたものをリストアップしておきます。
どのアプリでも出てくる共通的なネタも多いと思うので、取捨選択の参考になれば幸いです。

  • 生成した画像をツイートできる機能
    • → 認証が必要になるので面倒(ユーザーも面倒)
    • → 著作権違反の画像を使われた場合など、無意味に火の粉をかぶる可能性があるので面倒
  • 生成した画像にアプリのクレジットを入れる機能
    • → 宣伝にはなるかもだけど、ユーザー的には嬉しくない
    • → 著作権違反の画像...(ry
  • 画像にフレームや文字を加える機能
    • → 画像系サービスだとやりたくなるけど、機能・コンテンツを作り込むほど必要な工数もリニアに膨らむのできつい
    • → バズってまだ開発したいならその時につくったらいいと思う。割り切れ:imp:
  • 既存のGIFアニメにさらに「ふるふる」をかけて保存する機能
    • → 単純に実装が2倍くらい面倒になるのが見えていたので見送り

もちろんこれを作ってはいけない、ということではないです:wink:
ただ、挫折する可能性があるなら全部後回しにして、まずはリリースを優先してみた方が、結果としては得るものが大きいはずです。

コアの機能を作る→公開する→やる気をだす

コアの機能をプロトタイピングしてそれが本当にできそうか試してみます。
今回は「適当な画像に対してSVGのフィルターを使ってふるふるさせる」という機能が実現できるか・実現したら楽しいか、簡単なコードを書いて動かしてみます。

とりあえず作ってみたらTwitterあたりでつぶやいてみるのもよいです。

普段そんなに反応もらえないのに2桁ファボ貰えると俄然やる気になりますよね。ちなみに、

こっちの方が反応が良かったのでこの路線も考えてみたけど、イマイチアプリとしてのアイデアに落ちなかったので今回は見送り。だれか面白いもの考え付いたら作ってください。

VueでWebアプリの体裁を作る

コアの機能ができたら、それを組み込むアプリのUIを作ります。
今回の構成は以下の通り:

  • FW: Vue2 + CompositionAPI
  • 言語 : TypeScript
  • ホスティング : Firebase

個人的に最近のお気にな構成ですが、特筆することはないと思います。
ホスティングは別にGithubPagesとかでもいいのだけど、今回Firebaseでfulful.web.appっていい感じのサブドメインが取れたのでこれ使いたいがためだけにFirebaseにしています。もちろん無料枠:hugging:

後回しにしていた面倒な機能を作る

大体画面ができて遊べるようになったら、後回しにしていためんどくさい部分を作ります。
今回はGIFで保存する部分を後回しにしていたので頑張って調べて実装します。
:kissing: めんどくさいけどやればまあ、できるっしょ」的な機能は個人開発ではモチベ維持のために後回しにされることが多いのですが、判断を誤るとここまで作ったアプリがリリースできずに全て捨てることにもなりかねないので注意。

初めてのものを作るときはある程度、最初のステップでプロトタイピングしておく方が安全です(自戒、後述します...)。

動作検証して爆死する

必要な機能が全部できたら実機でテストしましょう。大体ちゃんと動きません。
実際には、今回は開発中から頻繁に実機(iPhone)で動作確認していたのですが、出来上がったものを数人にみてもらったところ動かないケースが散発。。
後半の技術ネタでもちょっと触れますが、SVGのアニメーションはブラウザ差異やバグが結構多いのでハマりやすいです。

でもここまできたらモチベ的には頑張れるはず。なんとかごまかしてリリースまで持っていきましょう。

OGPとAnalyticsを仕込んでリリース

SNSで使ってもらうWebアプリであれば、やっぱり最低限OGP画像は用意しておきたいですよね。今回は面倒なので全部静的な設定だけで済ませます。
該当部分:/public/index.html#L10

あとはTwitterで宣伝しつつ、遊んでくれた方の画像をひたすらRTしてました。
ここまでで2日。週末の趣味開発としてはいい感じにまとめられたと思います。

技術的なポイント

これだけだとほとんどポエムなので、後半は技術的なポイントも解説したいと思います。

SVGフィルタでふるふる

冒頭でも書いたように、今回の「ふるふるするエフェクト」は全てSVGのフィルタ機能で実現しています。

SVGを「拡大してもにじまない画像」くらいに認識している方も少なくないと思いますが、
実のところSVGは超絶複雑&高度なフィルタとアニメーション機構を持った:angel::angel:フォーマットです。神過ぎて永久にV2が実装されないくらいの神。

なので人間には全貌を理解するのはちょっと無理があるのですが、https://yoksel.github.io/svg-filters/ のようなサービスを使うとドラッグ&ドロップでどんなことができそうか理解することは可能です。SVGに俄然興味の湧いてきたみなさんは是非一度遊んでみてください。

SVGフィルタをVueで作る

今回はfeTurbulencefeDisplacementMapを使って画像にノイズをて適用する簡単なフィルタをつくり、このノイズをアニメーションさせることで「ふるふる」の動きを作り出します。せっかくVueで作っているので、SVGフィルタもVueのコンポーネントで作りましょう。

/src/components/FulfulFilter.vue

FulfulFilter.vue(テンプレート部分)
<template>
  <!-- 手描き感のあるラフな表現を加えるフィルターの定義 -->
  <filter :id="filterId" :filterUnits="filterUnits" x="0" y="0" :width="areaW" :height="areaH">
    <!-- ノイズを生成する原始フィルター -->
    <feTurbulence type="turbulence" :numOctaves="octaves" :seed="noiseSeed" stitchTiles="stitch" :baseFrequency="`${frequency} ${frequency}`">
      <!-- ノイズのシードを変化させる -->
      <animate 
        v-if="animated"
        attributeName="seed"
        from="1"
        to="100"
        dur="10s"
        repeatCount="indefinite"
      />
    </feTurbulence>
    <!-- ノイズを元に画像を歪める -->
    <feDisplacementMap in="SourceGraphic" :scale="scale"/>
  </filter>
</template>

SVGの要素はちょっと見慣れないかもしれませんが、Vueとしてはごく普通のコンポーネントです。コメントにあるとおり、フィルタの子要素にanimate要素を入れて、ここでノイズを生成するseedの値を変化させてあげています。

SVGフィルタのコンポーネントを適用する

フィルタができたら、これをSVGの画像に適用します。

/src/components/FilterStage.vue

FilterStage.vue(テンプレート部)
<svg viewBox="0 0 500 500" ref="svgRef">
  <defs>
    <!-- フィルタコンポーネントを組み込み -->
    <FulfulFilter
      filterId="main-filter"
      :noiseSeed="filterState.seed"
      :animated="false"
      :scale="filterState.noiseScale"
      :frequency="filterState.noiseFrequency / 1000"
      filterUnits="userSpaceOnUse"
      areaW="500px"
      areaH="500px"
    />
  </defs>
  <!-- 組み込んだフィルタを適用 -->
  <g :filter="filterState.visible ? 'url(#main-filter)' : 'none'">
    <image
      v-if="state.imageDataUrl"
      :href="state.imageDataUrl"
      ...略...
    />
  </g>
</svg>

defsの中でFulfulFilterコンポーネントを呼んでフィルタをセットアップします。実際に適用している部分は少し下:filter="filterState.visible ? 'url(#main-filter)' : 'none'"の部分ですね。

なかなか実践する機会はないかもしれませんが、SVGの要素、特にフィルタやクリッピングパスのような面倒な定義をVueでコンポーネントにするテクは覚えておくとスマートなSVGの実装ができるかもしれません。

フロントだけでアニメGIFを生成

「フルフルネコチャン」実装のもう一つの山が、表示しているふるふる画像をGIFアニメとして保存する機能です。GIFアニメ生成系のサーバサイドを使っているものも多そうですが、せっかく画面上で全部出来上がっているので、できればこれをそのままクライアント側でGIFに出力したいところです。

アニメGIF出力ライブラリを使う

アニメGIFを作れるJSライブラリは派生を含めて結構色々あるようですが、今回はgif.jsというまんまの名前のライブラリを使ってみました。

npmやyarnでライブラリをインストールしたら、忘れずにパッケージのdistに含まれているgif.worker.js自分のプロジェクトのPublicディレクトリにコピーしましょう。ちゃんと説明読めば普通に書いてあるのですが、見落としているとハマります。。

なお、このライブラリはJavaScript(CoffeScript)で書かれており、TypeScriptの型定義は(@typesでも)提供されていないようです。多分派生やもっと新しいライブラリも含めて探せばどこかにはあるんだろうとは思うのですが、探すのもめんどくさかったので今回はざっくり必要な定義を自分で追加しています:/src/types/gif.js/index.d.ts

SVGアニメをアニメGIFに出力する

これで画像を渡すといい感じにライブラリがアニメGIFを生成してくれる仕組みが整いました。
公式のサンプルを見ると、↓のように簡単にGIFを生成できるようです(コメントは筆者)

gif.js使用例(公式から改変)
// GIFエンコーダのインスタンスを作る
var gif = new GIF({
  workers: 2,
  quality: 10
});

// imageオブジェクトを渡してアニメGIFのフレームを追加
gif.addFrame(imageElement);

// エンコード完了時のコールバックを設定
gif.on('finished', function(blob) {
  // GIFをURL形式で取得して表示
  window.open(URL.createObjectURL(blob));
});

// エンコードを開始
gif.render();

今回は単純なimageではなくSVGなので、このエンコーダをSVGでも使えるように拡張します。簡単に使えるように、今回はSVG→GIFアニメ専用のエンコードクラスを作りました。

Svg2Gif.ts
import GIF from 'gif.js'

export class Svg2Gif {
  private encoder: GIF
  private svgElem: HTMLElement

  // SVG要素と出力サイズを指定してエンコーダを初期化する
  constructor (svg: HTMLElement, width = 500, height = 500) {...}

  // 現在のSVGの内容をキャプチャしてGIFのフレームに追加
  async add (): Promise<void> {...}

  // addしたフレームのエンコードを開始
  // onProgressを指定すると進捗を都度教えてくれる
  async render (onProgress?: (progress: number) => void): Promise<Blob> {...}
}

全体のソースは/src/core/Svg2Gif.tsをみてください。このクラスのポイントは実際にSVGの内容をキャプチャするaddメソッドだけなので、そこだけ解説します。

addメソッドでは、いわゆる画像のインライン化と呼ばれるやり方でimg要素にSVGの内容をセットし、そのimg要素をGIFエンコーダに渡します。

Svg2Gif.ts
  // 現在のSVGの内容をキャプチャしてGIFのフレームに追加
  async add (): Promise<void> {
    return await new Promise((resolve, reject) => {
      // SVG要素を文字列に変換する。
      // SVGはxmlの一種なのでXMLSerializerで文字列化できる
      const data = new XMLSerializer().serializeToString(this.svgElem)
      const img = new Image()
      // 新しいimg要素にURLエンコードしたSVGの文字列をセット
      img.src = 'data:image/svg+xml;charset=utf-8;base64,' + btoa(unescape(encodeURIComponent(data)))
      // 読み込みが完了したら、img要素をGIFエンコーダに渡す
      img.onload = () => {
        this.encoder.addFrame(img, { delay: 100 })
        resolve()
      }
      img.onerror = () => {
        reject(new Error('error while render frame'))
      }
    })
  }

ここまで出来れば、あとは出力するだけです。Promise化しているので使うのも簡単ですね。

FilterStage.vue
const saveImage = async () => {
  // SVG要素をrefで取得
  const elm = svgRef.value
  if (!elm) { return }
  // 保存開始をemit
  ctx.emit('renderStart')
  // 自動アニメーションを止める
  filterState.animated = false
  // gifエンコーダ(上で作ったクラス)のインスタンスを生成
  const gif = new Svg2Gif(elm, 500, 500)
  // フルフルのseed値を変えながら10コマ分ループ
  const FRAME_COUNT = 10
  for (let i = 0; i < FRAME_COUNT; i++) {
    // seedを変更
    filterState.seed = i
    //  変更したseedでSVGが描画されるのを待つ
    // (nextTickでうまくいかないケースがあったため、あまり良くないけど適当な時間の待ちを入れています)
    await wait(100)
    // SVGをキャプチャしてフレームを追加
    await gif.add()
  }
  // エンコード開始して結果を取得
  // 合わせて引数で進捗変化時のコールバックを指定。
  // →これを親コンポーネントで受け取ってプログレスバーを表示します
  const blob = await gif.render((progress) => {
    ctx.emit('renderProgress', progress)
  })
  // 自動アニメーションを再開
  filterState.animated = true
  // 結果をemit
  ctx.emit('renderFinished', blob)
}

ソースのコメントにも書いたように、<animate>要素によるアニメーションをそのままアニメGIFに出力することはできないので、一旦<animate>によるアニメーションは止めて、手動で「パラメタ変更→その時点の画像を出力」を繰り返しています。ちょっと面倒ですが、VueでSVGフィルタをコンポーネントにしておけばpropsを変えるだけなので簡単ですね。

生成したGIFデータはダイアログのコンポーネントで画面に表示して、ユーザーが長押しで画像の保存ができるようにしています。

はまりどころ

最後に、おもにSVG周りでのハマりポイントを共有します。:bow:この章の内容は私が実際にハマった部分なので、より正確な情報をお持ちの方がいらしたらコメント等で教えてください:bow:

SafariはSVGアニメーションの安定性・パフォーマンスがイマイチ

あんまり特定ブラウザの悪口は言いたくないのですが(IE以外)、SafariのSVG対応はちょっと微妙な部分が多い印象です。今回のフルフルネコチャンではSafariで動かした際のパフォーマンス改善のために、画像やスライダーのドラッグ中はアニメーションを止めるといった細かいチューニングを行なっています。

FirefoxはSVGアニメーションをCSSでhtml要素に適用すると要素が消える

Chromeで見ていただいた方はわかると思うのですが、フルフルネコチャンは画面のUI全体にもふるふるふるえるSVGフィルタを適用しています。
これ自体はCSSで、

.some-element {
  filter: url(#SVGフィルタのID);
}

とするだけの簡単なお仕事...のはずなのですが、なぜかFirefoxでは画面全体が真っ白になってしまいました。。この利用方法自体はFirefoxでも問題なく使えるはずなので、アニメーションが絡むとうまくいかない、といった問題があるのかもしれません。

この部分はブラウザ判定をしてFirefoxならフィルタを適用しない...と思ったけど今見たらやってないですね。すみません。(モバイル環境でUIのアニメーションを動かさないようにする設定だけやって満足していた模様...。Firefox対応するならここに追記が必要です)

html要素を画像に変換するライブラリはあまりうまくいかない

画面で見えているものを画像に変換したい!と思って検索すると、html2canvasのようなしっかりしたライブラリがいくつかヒットすると思います。
これだけ見て「:kissing_smiling_eyes:ちょっと面倒そうだけどこれ使えば大丈夫かなー」とタカをくくってると痛い目に遭います(遭いました)。この手のライブラリはきちんと自分の欲しいものに対して機能するか先に動かして確認した方が良いです。

html2canvasに代表されるDOMの画像化ライブラリには大きく2タイプあり、大別すると
 1. 対象要素を根性でパースしてレンダリング
 2. 対象要素をSVGのforeignObjectに突っ込んでそのSVGをimg要素経由で出力
のどちらかになるようです。

どちらも複雑なSVGを出力するのには向かないようで、今回のフィルタ付きSVGをうまく画像化することのできるものは見つけられませんでした。

...というかSVGをforeignObjectでSVGに埋め込むくらいだったら、初めから自力で出力した方がいいですよね。ということで上の方で紹介したXMLSerializerを使った実装にたどりついています。

凝ったことをしているライブラリは大抵すんなりとは動いてくれないものなので、ちゃんと検証しつつ、ダメなら最低限のものを自力で実装する判断も必要です。

まとめ:GIF生成系アプリはVueだけ(サーバーサイドなし)で作れるよ!

「入力画像を元にごにょごにょしてGIFを出力する」というパターンは個人開発アプリとしては王道かつちょうど良いお手軽感ですし、アイデア次第ではバズるものも色々作れるはずです。

フルフルネコチャンを下敷きにしてVueでオリジナルのアプリ開発にチャレンジする方が増えてくれたら嬉しいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
201
Help us understand the problem. What are the problem?