Puppeteerでムービー作成

この記事は、[LIFULL Advent Calendar 2019の19日目（2019-12-19）](https://qiita.com/advent-calendar/2019/lifull)の記事です。  

##はじめに

先日、LIFULLのSETグループから新しいOSSツールが発表されました:hugging:
[Visual Testingに最適な画像差分検知ツール「Gazo-san」をOSS化しました]
(https://www.lifull.blog/entry/2019/12/16/110000)

この発表に触発され普段はバックエンド業務が多いのですが、
headless browser触って準備しておこうと思いPuppeteerの記事を書くことにしました
この記事で作成したものはこちらに。何かの参考になればと思います。
https://github.com/hirokawai/endroll

ちなみに、「Gazo-san」に関しては後日
「LIFULLその2 Advent Calendar 2019」カレンダーで記事が公開されるようで楽しみにしています。

##puppeteerで動画

とはいえ、キャプチャ撮影だけではなくもっと遊びたい。
AdventCalendarも後半戦になったので、ちょっと気が早いですがAdventCalendarの振り返り用ムービーを作ります。

動画生成元のHTMLを用意
→ Puppeteerで自動スクロール操作 
→ 1frameずつscreenshot作成 
→ ffmpegへストリームしてmp4作成

この手順で動画を作成していきます。
今回利用するツールのバージョンです。

```
% ffmpeg                                                                                         
ffmpeg version 4.2.1 Copyright (c) 2000-2019 the FFmpeg developers
  built with Apple clang version 11.0.0 (clang-1100.0.33.8)
node_modules
+ puppeter@2.0.0          
+ child_process@1.0.2    
+ gsap@3.0.2                                                                           
```

##動画生成元のHTMLを用意

Codropsから良さそうなHTMLを見つけてきます。
大変勉強させていただいています。Codropsマジで最高です。
https://github.com/codrops/SmoothScrollingImageEffects
このHTMLを参考にページを一部変更していきます。

画像がないと寂しいので、著作権フリーな[unsplash](https://unsplash.com/)の写真を取得できる[picsum](https://picsum.photos/)から取得します。

demo.jsの一部を改変しAjaxリクエストでAdventCalendarの記事取得処理するようにします。


```javascript
    const getAdventCalendarRSS = () => {
        return new Promise((resolve, reject) => {

            var xmlHttpRequest = new XMLHttpRequest();
            xmlHttpRequest.onreadystatechange = function()
            {
                if( this.readyState == 4 && this.status == 200 )
                {
                    if( this.response )
                    {
                        var content = document.getElementById("content");
                        var idx = 1;
                        for (const item of this.response) {
                            var str = `
                            <div class="content--full content--alternate">
                            <div class="content__item content__item--wide">
                            	<span class="content__item-number">${idx}</span>
                            	<div class="content__item-imgwrap"><div class="content__item-img" style="background-image: url(https://picsum.photos/960/540?random=${idx});"></div></div>
                            	<div class="content__item-deco">${item.author}</div>
                            	<h2 class="content__item-title">12/${idx}</h2>
                            	<p class="content__item-description">${item.title}</p>
                            </div>
                            </div>
                            `
                            var div = document.createElement('div');
                            div.innerHTML = str;
                            content.appendChild(div);
                            idx++;
                        }
                        xmlHttpRequest = null;
                        resolve();
                    }
                }
            }

            xmlHttpRequest.open( 'GET', 'http://hirokawai.mock.server/rss', true );
            xmlHttpRequest.responseType = 'json';
            xmlHttpRequest.send( null );
        });
    };

    // And then..
    getAdventCalendarRSS().then(() => {
        return preloadImages();
    }).then(() => {
        // Remove the loader
        document.body.classList.remove('loading');
        // Get the scroll position and update the lastScroll variable
        getPageYScroll();
        lastScroll = docScroll;
        // Initialize the Smooth Scrolling
        new SmoothScroll();
    });
```

HTMLは記事取得を行いDOMがappendされるのを期待します

```html

<!DOCTYPE html>
<html lang="en" class="no-js">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<link rel="shortcut icon" href="favicon.ico">
		<link rel="stylesheet" href="https://use.typekit.net/dec4mzz.css">
		<link rel="stylesheet" type="text/css" href="css/base.css" />
	</head>
	<body class="demo-1 loading">
		<main>
			<div data-scroll class="page page--layout-2">
				<h1 class="page__title">LIFULL ADVENT CALENDAR 2019</h1>
				<div id="content" class="content">
				</div>
			</div>
		</main>
		<script src="js/imagesloaded.pkgd.min.js"></script>
		<script src="js/demo.js"></script>
	</body>
</html>
```

##Puppeteerで自動スクロール操作

いよいよPuppeteerを操作していきます
以下のコマンドで実行できるように作成します

```shell
node capture.js [filepath] [viewport-width] [viewport-height] [deviceScaleFactor]

例）
node capture.js 'file:///Users/hirokawai/Desktop/adcalendar.html' 960 540 2
```

capture.js実装は以下のようにします

```javascript

(async() => {
    // ※1) ffmegのオプションを設定してstreamに書き込んでいく
    // ※2) puppeteerを起動
    // ※3) スクレイピング
    // ※4) timelineを設定
    // ※5) 1frameずつstream処理
})();
```
#### ※1) ffmegのオプションを設定してstreamに書き込んでいく
ffmpegオプションを定義し外部コマンド実行形式で処理をさせます

```javascript
const ffmpegArgs = fps => [
    '-y',
    '-f',
    'image2pipe',
    '-r',
    `${+fps}`,
    '-i',
    '-',
    '-c:v',
    'libx264',
    '-auto-alt-ref',
    '0',
    '-pix_fmt',
    'yuva420p',
    '-metadata:s:v:0',
    'alpha_mode="1"'
]
    var ffmpegPath = options.ffmpeg || 'ffmpeg';
    var fps = options.fps || 30;
    var outFile = options.output;

    const args = ffmpegArgs(fps);
    args.push(outFile || '-');

    const ffmpeg = spawn(ffmpegPath, args);
    if (options.pipeOutput) {
        ffmpeg.stdout.pipe(process.stdout);
        ffmpeg.stderr.pipe(process.stderr);
    }

const write = (stream, buffer) =>
  new Promise((resolve, reject) => {
    stream.write(buffer, error => {
      if (error) reject(error);
      else resolve();
    });
});
```

#### ※2) puppeteerを起動

puppeteerの起動オプションを設定して先程用意したHTMLファイルをロードします。
networkidleを監視し、リクエストの完了とready判定まで待ちます。

```javascript
    const viewportHeight = parseInt(process.argv[3])
    const viewportWidth = parseInt(process.argv[4])
    const browser = await puppeteer.launch({
        headless: false,
        devtools: true,
        defaultViewport: {
            width: viewportWidth,
            height: viewportHeight,
            deviceScaleFactor: parseFloat(process.argv[5]),
        },
    });
    const page = await browser.newPage();

    await page.goto(
        process.argv[2]
        , { waitUntil: 'networkidle0' }
    );
    await page.waitFor(() => !document.querySelector(".loading"));
```

#### ※3) スクレイピング

記事のリストを作成するためにadvent calendarページをスクレイピングして取得します。
page.evaluateで該当の部分から日付・アカウント・タイトルを取得し用意したHTMLへ渡すようにします。
await page.setRequestInterception(true);でリクエストを監視しスクレイピングしたデータをインターセプトしています。

```javascript

    await page.goto(
        "https://qiita.com/advent-calendar/2019/lifull"
        , { waitUntil: 'networkidle0' }     
    );

    const scrapingData = await page.evaluate(() => {
        const dataList = [];
        const nodeList = document.querySelectorAll(".adventCalendarItem");

        nodeList.forEach(_node => {
            const calendar = _node.children[0].innerText
            const author = _node.children[1].children[0].getAttribute('href').slice(1);
            const title = _node.children[1].children[1].firstElementChild.innerText
            dataList.push({calendar,author,title});
        })
        return dataList;
    });

    await page.setRequestInterception(true);
    page.on('request', request => {
        if (request.url() === "http://hirokawai.mock.server/rss") {
            request.respond({
                content: 'application/json',
                headers: {"Access-Control-Allow-Origin": "*"},
                body: JSON.stringify(scrapingData)
            });
        }
        else {
            request.continue();
        }
    });
```

#### ※4) timelineを設定

スクロールの制御にはGSAPのtimelineを利用します。GSAP3がリリースされ利用方法が少し変わりましたね。
（GSAP３新機能が増えていてますね！DEMOがすごい・・・ https://greensock.com/3/）
セレクタをハードコードしていて美しくはないですが、スクロール対象間の移動量を計算していきます。

```javascript

　　 // ページにスクロール制御用に利用するgsapを追加する
    await page.addScriptTag({ path: './node_modules/gsap/dist/ScrollToPlugin.min.js' });
    await page.addScriptTag({ path: './node_modules/gsap/dist/gsap.min.js' });

　　 // 1記事の範囲を計算するためのclassを指定
　　 // timelineのスクロールに利用する全ての記事の座標を取得
    const $item = await page.$('.content__item-title');
    const bounding_box = await $item.boundingBox();
    const selector = ".content__item";

    await page.waitForSelector(selector);
    const items = await page.evaluate(selector => {
        let positions = []
        const elements = Array.from(document.querySelectorAll(selector));
        for (const ele of elements) {
            const {x, y, width, height} = ele.getBoundingClientRect();
            positions.push({x, y, width, height})
        }
        return positions;
    }, selector);

    await page.evaluate(async (items, margin) => {
        gsap.registerPlugin(ScrollToPlugin);
        var tl = gsap.timeline();
        tl.pause();
        for (const item of items) {
　　 　　　　 // １記事に対するスクロールを定義
            tl.add(gsap.to(window, {duration: 3.5, scrollTo: item.y - margin, ease: "expo.inOut"}));
        }
        window.timeline = tl
        return Promise.resolve()
    }, items, bounding_box.height);
```

#### ※5) 1frameずつstream処理

timelineを進めながらキャプチャを生成していきます。

```javascript

　　 // fpsに対する総frame数を計算する
    const frames = await page.evaluate(async _fps =>
      Math.ceil(window.timeline.duration() / 1 * _fps)
    , fps)
    let frame = 0

    const nextFrame = async () => {

        // timelineの進行をコントロール
        await page.evaluate(async progress => {
          window.timeline.progress(progress)
          await new Promise(r => setTimeout(r, 16))
        }, frame / frames)

        // 見えている範囲をキャプチャする
        let screenshot = await page.screenshot({
            fullPage: false,
        });
        await write(ffmpeg.stdin, screenshot)
        frame++

        console.log(`frame ${frame} / ${frames}`)
        if (frame > frames) {
          console.log('done!')
          ffmpeg.stdin.end()
          await closed
          await browser.close()
          return
        }
        nextFrame()
    }
    nextFrame()
```

##出来上がり

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10683/53befac1-915c-fa99-1ac7-f9c871f8139e.gif)

出来上がりの一部です。puppeteerで動画を作成できました:clap:
puppeteerは様々なautomationが可能で非常に有用なツールですね。
個人的にはテストやスクレイピングなどのイメージがありましたが、こういった利用も面白いのではないでしょうか。

明日以降もLIFULLアドベントカレンダーは続きます。引き続きお楽しみに！
https://qiita.com/advent-calendar/2019/lifull