8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

LIFULLAdvent Calendar 2019

Day 19

Puppeteerでムービー作成

Last updated at Posted at 2019-12-18

この記事は、LIFULL Advent Calendar 2019の19日目(2019-12-19)の記事です。

##はじめに

先日、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の写真を取得できるpicsumから取得します。

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

    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されるのを期待します


<!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を操作していきます
以下のコマンドで実行できるように作成します

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

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

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


(async() => {
    // ※1) ffmegのオプションを設定してstreamに書き込んでいく
    // ※2) puppeteerを起動
    // ※3) スクレイピング
    // ※4) timelineを設定
    // ※5) 1frameずつstream処理
})();

※1) ffmegのオプションを設定してstreamに書き込んでいく

ffmpegオプションを定義し外部コマンド実行形式で処理をさせます

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判定まで待ちます。

    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);でリクエストを監視しスクレイピングしたデータをインターセプトしています。


    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がリリースされ利用方法が少し変わりましたね。
(GSAP3新機能が増えていてますね!DEMOがすごい・・・ https://greensock.com/3/)
セレクタをハードコードしていて美しくはないですが、スクロール対象間の移動量を計算していきます。


   // ページにスクロール制御用に利用する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) {
        // 1記事に対するスクロールを定義
            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を進めながらキャプチャを生成していきます。


   // 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

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

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

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?