LGTM gifアニメーションを作ろう

LGTM gifアニメーションを作ろう

f:id:machida-yosuke:20181205183744j:plain

12/10のアドベントカレンダーは、
HTMLファイ部で雰囲気フロントエンジニアのマチダがお送りします。

去る11月25日に行われたHTML5 Conference 2018
(https://events.html5j.org/conference/2018/11/)
において、弊社オリジナルコンテンツ
Looks Good To Myself
を展示しました。

https://cl-fibe-conf-lgtm-1302249.firebaseapp.com/#/
iOSの人はSafari、Androidの人はChromeでみてね!! (もしかしたら閉じてるかも)

あなただけのオリジナルLGTM(Looks Good To Me) GIF動画を作ろう!
専用のWebアプリからカメラで撮影するとGIF動画が生成され、
デジタルサイネージにそのまま反映されます!
用意されたカメラフィルターを使えば、
よりクリエイティブなLGTM GIF動画が作れるかも?

ちなみに去年は
Final Flash 2020
という寄せ書きコンテンツを
Flash
で作成しました。
http://create.kayac.com/frontend/final-flash-2020/

LGTMとは

"Looks Good To Me”
つまり「自分的にはOK」ということです。

PullRequestなどのコードレビューで、レビュアーがOKを出すときにLGTMとコメントする習慣があります。
「とりあえず動いてるからおk!」みたいなときでもLGTMです。

LGTM

単に文字列としてLGTMとコメントするより、画像を貼り付けてほっこりしようぜ、という文化です。

animation gifとは

GIFの読み方は「ぎふ」「じふ」。公式は「じふ」
無限にループできて、どのブラウザでも見られる最強のアニメーションイメージ(動画)ファイルです。
色やフレームレートの設定をちゃんとすれば容量は大きくなりません。
LGTM

やりたいこと

自分的にはOKという意思をgifアニメーションで表現でき、ネイティブアプリはインストール面倒だからwebアプリにし、ついでにいろんなエフェクトつけてTikTokみたいにしたい

フィジビリティチェック

gifアニメーション撮影 = webRTC
gifアニメーション保存 = gif.js できる
エフェクト TikTok = WebGL、shader
webアプリ = できる

つまりできる

準備編

デザインについて

まずはデザインを作ります。今回はAdobe XDとAdobe Illustratorを使います
https://www.adobe.com/jp/products/xd.html
XDを使えばUIだけでなく画面遷移などのプロトタイプを手頃に
作成、デバイスで確認、他のメンバーに共有してフィードバックをもらうこともできます。
f:id:machida-yosuke:20181205094059j:plain

gifのloadや生成時にユーザーを飽きさせないように専用のgifをAdobe AfterEffectsで作ります。 f:id:machida-yosuke:20181205125229g:plain

フロント実装について

webアプリ(SPA)

まずはwebアプリを手軽につくるため vue-cli3を使います
vue-cli3から設定ファイル不要(0configっていうらしい)になりました。これがまた不便
vue.config.jsを作ってwebpack設定しましょう
https://cli.vuejs.org/

// vue.config.js

module.exports = {
  // 全体で使うcssを指定
  css: {
    loaderOptions: {
      sass: {
        data: '@import "@/assets/scss/common.scss";',
      },
    },
  },
  // 今までにない設定を変える
  // shaderのファイルを便利に扱うやーつ
  configureWebpack: {
    module: {
      rules: [
        {
          test: /\.(fs|vs|glsl)$/,
          use: [
            {
              loader: 'glsl-shader-loader',
            },
          ],
        },
      ],
    },
  },
};

WebRTC

gifアニメーション撮影に、
WebRTCというリアルタイムコミュニケーション用のAPIからカメラのストリーム情報を MediaDecices.getUserMedia()で取得します。

// getUserMedia.js
// getUserMedia関数の引数にvideoのdomを渡します。
export default async function getUserMedia(dom) {
  const video = dom;
  // audioはいらない
  const medias = {
    audio: false,
    video: {},
  };
  // フロントカメラかバックカメラどちらか
  medias.video.facingMode = { exact: 'environment' };
  return navigator.mediaDevices
    .getUserMedia(medias)
    .then(stream => {
      // streamを取得して、videoに描画
      video.srcObject = stream;
      return true;
    })
    .catch(err => {
      console.log(err, '対応していません');
      return false;
    });
}
Apple iOSの場合

WebRTCは、iOS11以上でしか使えないし、
ブラウザはSafari以外では動きません

Androidの場合

みんなChromeつかってるので、
とくになにもしなくておk

gif保存

撮影した映像データをgifに変換して保存できるようにします。
gif変換と保存機能両方できる便利ライブラリがあるので使います。
https://github.com/spite/ccapture.js/

ccapture.js内でgif.jsを使うのでこちらも
http://jnordberg.github.io/gif.js/

// capture.js

import { framerate } from './config';

export default class Capture {
  constructor() {
    // インスタンス生成。ここでformatをえらぶ。
    // WebWorkerで動かす
    this.capturer = new CCapture({
      framerate,
      verbose: true,
      format: 'gif',
      workersPath: './js/',
    });
  }

  start() {
    this.capturer.start();
  }

  stop() {
    this.capturer.stop();
  }

  capture(canvas) {
    this.capturer.capture(canvas);
  }

  save() {
    return new Promise(resolve => {
      this.capturer.save(blob => {
        console.log(blob);
        resolve(blob);
      });
    });
  }
}

撮ったgifをみんなで共有できたほうがいいじゃんということで、
firebaseを使ってサービスアプリに変更しましょう
https://firebase.google.com/?hl=ja

// firebase.js

import * as firebase from 'firebase/app';
import 'firebase/database';
import 'firebase/storage';
import EventEmitter from 'events';
import config from './firebaseConfig';
import { FIREBASE_PATH } from './config';

firebase.initializeApp(config);
const db = firebase.database();
const dbDatas = db.ref(`${FIREBASE_PATH.ROOT_PATH}${FIREBASE_PATH.DATAS_PATH}`);
const storageRef = firebase.storage().ref();

class FireBaseManager extends EventEmitter {
  constructor() {
    super();
    this.isPostGif = false;
  }

  getGif() {
    this.onEmitGetData = snapshot => {
      this.emit('emitGetData', snapshot.val());
    };

    dbDatas
      .orderByKey()
      .limitToLast(30)
      .once('value', this.onEmitGetData);
  }
  // gifをストレージに保存後。データベースに情報をいれる
  async postGif(src) {
    if (this.isPostGif) return;
    this.isPostGif = true;
    const uploadRef = await storageRef.child(`${src.name}.gif`);
    const snapshot = await uploadRef.put(src.blob);
    console.log(snapshot, 'snapshot');
    const url = await uploadRef.getDownloadURL();
    const createdTime = firebase.database.ServerValue.TIMESTAMP;
    const dbpush = dbDatas.push();
    // データベースに情報をいれる
    dbpush.set({
      uid: dbpush.key,
      url,
      effect: src.effect,
      timestamp: createdTime,
    });
    this.isPostGif = false;
    this.emit('postComp');
  }
}

const firebaseManager = new FireBaseManager();
export default firebaseManager;

WebGL

WebGL側はThree.jsで簡単に実装しちゃいましょう。
WebRTCでカメラの情報をVideoのdomに映して、 Videoの情報をWebGL側に送ります。
Videoはautoplayにしないと動きません。
requestAnimetionではなく、TweenMaxのtickを使用します(便利だから)。
エフェクトの切り替えはuniformで 0 or 1 を渡して、切り替えます。
なのでエフェクト分のuniformを定義して、Switch文で管理します。
gif撮影中は容量削減のため,フレームレートを一気に下げます。

TweenMax.ticker.fps(framerate);
// webgl.js

import * as THREE from 'three';
import { TweenMax } from 'gsap/TweenMax';
import EventEmitter from 'events';
import frag from '../shader/fragment.fs';
import vert from '../shader/vertex.vs';
import Capture from '@/assets/js/Capture';
import { framerate, timeLimit, EFFECTS_LIST } from './config';

// fpsを30にする、秒間60も必要ない
TweenMax.ticker.fps(30);
// 保険
TweenMax.lagSmoothing(1000, 20);

const LAPLACIAN = [-1.0, -1.0, -1.0, -1.0, 8.0, -1.0, -1.0, -1.0, -1.0];
export default class WebglCamera extends EventEmitter {
  constructor() {
    super();
    this.capturer = new Capture();
  }

  setDom(canvas, video) {
    this.canvas = canvas;
    this.video = video;
    this.isCapturer = false;
    this.clock = new THREE.Clock();
    this.count = 0;
    this.initThree();
  }

  setEffect(num) {

    this.effect = EFFECTS_LIST[num];
    this.uniforms.uIsSymmetry.value = 0.0;
    this.uniforms.uIsNormal.value = 0.0;
    this.uniforms.uIsMosaic.value = 0.0;
    this.uniforms.uIsMonochrome.value = 0.0;
    this.uniforms.uIsHsv.value = 0.0;
    this.uniforms.uIsHalftone.value = 0.0;
    this.uniforms.uIsInverse.value = 0.0;
    this.uniforms.uIsEdge.value = 0.0;
    this.uniforms.uIsInsta.value = 0.0;
    this.uniforms.uIsChromatic.value = 0.0;
    this.uniforms.uIsKaleidoScope.value = 0.0;
    this.uniforms.uIsVhs.value = 0.0;
    this.uniforms.uIsToon.value = 0.0;
    this.uniforms.uIsGlitch.value = 0.0;
    this.uniforms.uIsSen.value = 0.0;

    switch (this.effect) {
    case 'シンメトリー':
      this.uniforms.uIsSymmetry.value = 1.0;
      break;
    case 'ノーマル':
      this.uniforms.uIsNormal.value = 1.0;
      break;
    case 'モザイク':
      this.uniforms.uIsMosaic.value = 1.0;
      break;
    case '白黒':
      this.uniforms.uIsMonochrome.value = 1.0;
      break;
    case 'hsv':
      this.uniforms.uIsHsv.value = 1.0;
      break;
    case 'ハーフトーン':
      this.uniforms.uIsHalftone.value = 1.0;
      break;
    case 'エッジ':
      this.uniforms.uIsEdge.value = 1.0;
      break;
    case 'インスタ':
      this.uniforms.uIsInsta.value = 1.0;
      break;
    case '色反転':
      this.uniforms.uIsInverse.value = 1.0;
      break;
    case '色収差':
      this.uniforms.uIsChromatic.value = 1.0;
      break;
    case 'VHS':
      this.uniforms.uIsVhs.value = 1.0;
      break;
    case '万華鏡':
      this.uniforms.uIsKaleidoScope.value = 1.0;
      break;
    case 'トゥーン':
      this.uniforms.uIsToon.value = 1.0;
      break;
    case 'グリッチ':
      this.uniforms.uIsGlitch.value = 1.0;
      break;
    case '線':
      this.uniforms.uIsSen.value = 1.0;
      break;

    default:
      this.uniforms.uIsNormal.value = 1.0;
      break;
    }
  }

  initThree() {
    this.width = this.video.videoWidth;
    this.height = this.video.videoHeight;
    this.canvas.width = this.width;
    this.canvas.height = this.height;
    this.videoTexture = new THREE.VideoTexture(this.video);
    this.videoTexture.minFilter = THREE.LinearFilter;
    this.videoTexture.magFilter = THREE.LinearFilter;
    this.videoTexture.mapping = THREE.ClampToEdgeWrapping;
    this.videoTexture.format = THREE.RGBFormat;

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(
      40,
      this.width / this.height,
      0.1,
      2,
    );
    this.camera.lookAt(new THREE.Vector3(0.0, 0.0, 0.0));
    this.camera.position.z = 1;

    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas });
    this.renderer.setClearColor(0xffffff);
    this.renderer.setSize(this.width / 2, this.height / 2);
    this.renderer.setPixelRatio(1);

    this.uniforms = {
      uTexture: {
        type: 't',
        value: this.videoTexture,
      },
      uTime: {
        type: 'f',
        value: this.time,
      },
      uResolution: {
        type: 'v2',
        value: [this.width / 2, this.height / 2],
      },
      uIsNormal: {
        type: 'f',
        value: 1.0,
      },
      uIsSymmetry: {
        type: 'f',
        value: 0.0,
      },
      uIsMosaic: {
        type: 'f',
        value: 0.0,
      },
      uIsMonochrome: {
        type: 'f',
        value: 0.0,
      },
      uIsHsv: {
        type: 'f',
        value: 0.0,
      },
      uIsHalftone: {
        type: 'f',
        value: 0.0,
      },
      uIsEdge: {
        type: 'f',
        value: 0.0,
      },
      uIsInsta: {
        type: 'f',
        value: 0.0,
      },
      uIsInverse: {
        type: 'f',
        value: 0.0,
      },
      uLaplacian: {
        type: '1fv',
        value: LAPLACIAN,
      },
      uIsChromatic: {
        type: 'f',
        value: 0.0,
      },
      uIsKaleidoScope: {
        type: 'f',
        value: 0.0,
      },
      uIsVhs: {
        type: 'f',
        value: 0.0,
      },
      uIsToon: {
        type: 'f',
        value: 0.0,
      },
      uIsGlitch: {
        type: 'f',
        value: 0.0,
      },
      uIsSen: {
        type: 'f',
        value: 0.0,
      },
    };

    const mesh = new THREE.Mesh(
      new THREE.PlaneGeometry(0.75, 1),
      new THREE.RawShaderMaterial({
        fragmentShader: frag,
        vertexShader: vert,
        uniforms: this.uniforms,
      }),
    );
    this.scene.add(mesh);

    // レンダリング
    this.render = () => {
      this.time = this.clock.getElapsedTime();
      this.uniforms.uTime.value = this.time;

      this.renderer.render(this.scene, this.camera);
      this.videoTexture.needsUpdate = true;

      if (this.isCapturer && this.count === framerate * timeLimit) {
        this.isCapturer = false;
        this.count = 0;
        this.capturer.stop();
        this.emit('captureComp');
        this.capturer.save().then(blob => {
          this.emit('saveComp', blob);
        });

        TweenMax.ticker.fps(30);
        this.capturer = new Capture();
      }
      if (this.isCapturer) {
        this.count += 1;
        this.emit('decrementCount', this.count);
        this.capturer.capture(this.canvas);
      }
    };
    TweenMax.ticker.addEventListener('tick', this.render);
  }

  startCapture() {
    this.isCapturer = true;
    TweenMax.ticker.fps(framerate);
    this.capturer.start();
  }

  destroy() {
    TweenMax.ticker.removeEventListener('tick', this.render);
  }
}

エフェクト

GLSLのfragment shaderを使ってエフェクトを作成します。
あるあるエフェクト集てきなものを調べて実装するだけですね。
(ハーフトーン、色収差、モザイクとか)
エフェクトをエフェクト名ずつ別ファイル(モジュール化)にしましょう
昔、glslifyというものがありまりましたが、時代はwebpackになってしまったので、代わりのものを探します。
https://qiita.com/yuichiroharai/items/ecbfd2d7729c7384fb3a

glsl-shader-loaderを使います。
https://www.npmjs.com/package/glsl-shader-loader
使い方は簡単で

#pragma loader: import randomDirection from './collections/random.glsl';

みたいな書き方ですね。

ちなみに

precision highp float;

にしないとiOSデバイスでちゃんと描画されないので気をつけましょう。

WebGL Schoolとか
GLSL Schoolに通って勉強してみてください。 https://webgl.souhonzan.org/?category=tagged&v=school

// fragment.fs

precision highp float;

uniform sampler2D uTexture;
uniform float uTime;
uniform vec2 uResolution;
uniform float uIsSymmetry;
uniform float uIsNormal;
uniform float uIsMosaic;
uniform float uIsMonochrome;
uniform float uIsHsv;
uniform float uIsHalftone;
uniform float uIsInverse;
uniform float uIsEdge;
uniform float uIsInsta;
uniform float uLaplacian[9];
uniform float uIsChromatic;
uniform float uIsKaleidoScope;
uniform float uIsVhs;
uniform float uIsToon;
uniform float uIsGlitch;
uniform float uIsSen;

varying vec2 vUv;

const float PI = 3.14;
const float TAU = PI * 2.0;
#pragma loader: import normal from './normal.fs';
#pragma loader: import symmetry from './symmetry.fs';
#pragma loader: import inverse from './inverse.fs';
#pragma loader: import monochrome from './monochrome.fs';
#pragma loader: import mosaic from './mosaic.fs';
#pragma loader: import hsv from './hsv.fs';
#pragma loader: import halftone from './halftone.fs';
#pragma loader: import edge from './edge.fs';
#pragma loader: import insta from './insta.fs';
#pragma loader: import chromaticAberration from './chromaticAberration.fs';
#pragma loader: import kaleidoScope from './kaleidoScope.fs';
#pragma loader: import vhs from './vhs.fs';
#pragma loader: import toon from './toon.fs';
#pragma loader: import glitch from './glitch.fs';
#pragma loader: import sen from './sen.fs';

void main () {
  vec2 texcoord = gl_FragCoord.st / uResolution;
  // -1 ~ 1にするやーつ
  vec2 p = texcoord * 2.0 - 1.0;

  vec4 normal = normal(texcoord) * uIsNormal;
  vec4 symmetry = symmetry() * uIsSymmetry;
  vec4 monochrome = monochrome(texcoord) * uIsMonochrome;
  vec4 mosaic = mosaic() * uIsMosaic;
  vec4 hsv = hsv(texcoord) * uIsHsv;
  vec4 Halftone = halftone(texcoord) * uIsHalftone;
  vec4 inverse = inverse(texcoord) * uIsInverse;
  vec4 edge = edge(texcoord) * uIsEdge;
  vec4 insta = insta(texcoord, p) * uIsInsta;
  vec4 chromaticAberration = chromaticAberration(texcoord) * uIsChromatic;
  vec4 vhs = vhs(texcoord, p) * uIsVhs;
  vec4 kaleidoScope = kaleidoScope(texcoord) * uIsKaleidoScope;
  vec4 toon = toon(texcoord) * uIsToon;
  vec4 glitch = glitch(texcoord) * uIsGlitch;
  vec4 sen = sen(texcoord) * uIsSen;

  vec4 color = toon + symmetry + normal + monochrome + mosaic + hsv + Halftone + edge + insta + inverse + vhs + kaleidoScope + chromaticAberration + glitch + sen;

  gl_FragColor = color;
}
// normal.fs
vec4 normal(vec2 uv){
  vec4 texture = texture2D(uTexture, uv);
  return texture;
}
// glitch.fs
vec4 glitch(vec2 uv){
    float PI = 3.1415;
    float moveX = sin(uTime * 100.0) * 0.01;
    float moveY = sin(uTime * 100.0 + ( PI / 4.0 )) * 0.001;

    vec2 muv1 = vec2(floor(uv.x * 2.0) / 2.0, floor(uv.y * 10.0) / 10.0) + uTime * 0.01;
    vec2 muv2 = vec2(floor(uv.x * 4.0) / 4.0, floor(uv.y * 16.0) / 16.0) + uTime * 0.98;
    vec2 muv3 = vec2(floor(uv.x * 8.0) / 10.0, floor(uv.y * 14.0) / 14.0) + uTime * 0.5;

    float noise1 = step(0.7, snoise(vec3(muv1 * 4.0, 1.0)));
    float noise2 = step(0.6, snoise(vec3(muv2 * 4.0, 1.0)));
    float noise3 = step(0.8, snoise(vec3(muv3 * 6.0, 1.0)));

    float mergeNoise = noise1 + noise2 + noise3;

    vec2 mergeUv = uv + mergeNoise * 0.1;
    vec4 texture = vec4(
        texture2D(uTexture, vec2(mergeUv.x - moveX, mergeUv.y - moveY)).r,
        texture2D(uTexture, mergeUv).g,
        texture2D(uTexture, mergeUv).b,
        1.0
    );

    // Output to screen
    return texture;
}

完成

これであなただけのLGTM gifを相手に叩きつけることができます。
f:id:machida-yosuke:20181205100052g:plain

gitのリポジトリはこちら。
オリジナルのエフェクトを作ってみましょう! github.com

まとめ

webって環境を限定すればなんでもできますね。
ブラウザがChromeだけの時代が来ませんかねぇ。
しかし、FANGは、世界の成長を止めてる説もありますし、
Chromeだけっていうのもやばいかもですね。うーん。

面白法人カヤックでは、LGTMボケに対してツッコミできるエンジニアを募集中です。
一番ほしいのはボケを引き立たせるツッコミ役です。

面白法人カヤックアドベントカレンダー、
明日はのken39argさんがGitHubへの愛を語ります。