LGTM gifアニメーションを作ろう
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とコメントするより、画像を貼り付けてほっこりしようぜ、という文化です。
animation gifとは
GIFの読み方は「ぎふ」「じふ」。公式は「じふ」
無限にループできて、どのブラウザでも見られる最強のアニメーションイメージ(動画)ファイルです。
色やフレームレートの設定をちゃんとすれば容量は大きくなりません。
やりたいこと
自分的には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だけでなく画面遷移などのプロトタイプを手頃に
作成、デバイスで確認、他のメンバーに共有してフィードバックをもらうこともできます。
gifのloadや生成時にユーザーを飽きさせないように専用のgifをAdobe AfterEffectsで作ります。
フロント実装について
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を相手に叩きつけることができます。
gitのリポジトリはこちら。
オリジナルのエフェクトを作ってみましょう!
github.com
まとめ
webって環境を限定すればなんでもできますね。
ブラウザがChromeだけの時代が来ませんかねぇ。
しかし、FANGは、世界の成長を止めてる説もありますし、
Chromeだけっていうのもやばいかもですね。うーん。
面白法人カヤックでは、LGTMボケに対してツッコミできるエンジニアを募集中です。
一番ほしいのはボケを引き立たせるツッコミ役です。
面白法人カヤックアドベントカレンダー、
明日はのken39argさんがGitHubへの愛を語ります。