Laravel+JavaScriptでGifアニメ作成アプリを作ってみる

テーマ

どうも!!naoto555です。
今回は、Laravel+JavaScriptでGIFメーカーを作成していく方法をご紹介していこうと思います。GIFは画像ファイル形式の一つで、短いアニメーションや動画を再生することができます。圧縮性能に優れており、動きがある画像を扱う場合によく使われます。↓のちんちくりんなヒヨコが歩いている動画がGIF画像です。このGIFでは、静止画5枚を0.5秒間隔に表示してアニメーションにしています。
(2024.2記載)

開発環境

統合開発環境 aws cloud9
PHP 8.1.16
Laravel 9.52
login機能 Laravel/jetstream(livewire)
Database SQlite(version 3.7.17)

解説

①パッケージをインストール

まずは、GIFを作成するためのパッケージをインストールします。コンポーザーの導入が必要になります。まだ導入されてない方はこちらの記事をご参照下さい。
PHP7.4以前のバージョンでは、GifCreatorという便利なライブラリがあったのですが、こちらのライブラリはPHP7.4以降のバージョンに対応していない。(2023/2現在の状況)
今回は、「ImageMagick」「imagick」「Intervention Image」というライブラリを使用して、作成していきたいと思います。
以下に各ライブラリの特徴と役割を簡単に説明します。

「ImageMagick」
画像を編集するためのコマンドラインツールおよびライブラリです。画像形式の変換、サイズ変更、回転、トリミング、効果の追加など、多様な機能を持っています。PHPなどのプログラム言語と組み合わせて利用されます。
「imagick」
imagickは、ImageMagickのPHPバインディングの1つで、ImageMagickをPHPで利用するためのライブラリです。imagickを使用することで、PHPでImageMagickを操作することができます。
「Intervention Image」
Intervention Imageは、PHPで画像処理を行うためのライブラリです。画像のリサイズ、回転、トリミング、効果の追加などの機能を提供します。ImageMagickまたはGD Libraryのどちらかが使用されますが、自動的に判別して使用するため、開発者は特に意識する必要がありません。Laravelフレームワークとの親和性が高く、Laravelプロジェクトでよく利用されます。

以下のコマンドをターミナルに打ち、ライブラリをインストールしていきます。

# ImageMagickをインストールする
sudo yum install -y ImageMagick

# imagickをインストールする
sudo rpm -ivh ftp://ftp.pbone.net/mirror/vault.centos.org/7.6.1810/cr/x86_64/Packages/LibRaw-0.19.2-1.el7.x86_64.rpm
sudo yum install php81-php-imagick

# Intervention Imageをインストールする
composer require intervention/image

 

②ストレージの有効化

以下のコマンドを打ち、ストレージを有効化していきます。JetStreamをインストールしている場合は、既にstorageフォルダが自動で生成されているので、”link already exists.”というエラーが出ますが、無視してgifsフォルダを作成してください。

# ストレージの有効化
php artisan storage:link

# 保存用のフォルダとして、public/storage/gifsを作成
mkdir -p public/storage/gifs

③ルーティングの設定

ルーティングの設定をしていきます。コントローラー名を「GifController」としました。
コントローラ名は自分で決めてもらって大丈夫ですが、自分が使いやすい名前を選ぶことが大切です。

route/web.php

use App\Http\Controllers\GifController; //追加

//--------------------- 中略 ---------------------

Route::get('/gifs/create', [GifController::class, 'create'])->name('gifs.create');
Route::post('/gifs/store', [GifController::class, 'store'])->name('gifs.store');

 

④コントローラの作成

ルーティングで設定した「GifController」を作っていきます。まずは以下のLinuxコマンドをターミナルで実行しコントローラを作成します。

php artisan make:controller GifController

コントローラーを作成したら、作成したコントローラの中身を編集していきます。

app/Http/Controllers/GifController.php

use Illuminate\Support\Facades\Storage;      //追加

//---------------------- 中略 ----------------------

public function create()
{
    return view('gifs.create');
}

public function store(Request $request)
{
    // アップロードされたファイルを取得
    $files = $request->file();

    // 画像ファイル数を$lengthに格納
    $length = count($files);

    // 画像の幅と高さを取得
    $width = $request->input('width');
    $height = $request->input('height');

    // GIFの作成
    $gif = new \Imagick();
    $gif->setformat('gif');

    // 画像ファイルを読み込み、コマを追加する
    for ($i = 0; $i < $length; $i++) {
        // アップロードされたファイルを読み込む
        $image = $files['image' . $i];
        $img = new \Imagick();
        $img->readImageBlob(file_get_contents($image->getRealPath()));

        //コマの表示時間の読込
        $display_sec = $request->input('image_sec'.$i);
        
        // 画像をリサイズする
        $img->resizeImage($width, $height, \Imagick::FILTER_LANCZOS, 1);

        // コマを追加する
        $gif->newImage($width, $height, new \ImagickPixel('transparent'));
        $gif->compositeImage($img, \Imagick::COMPOSITE_DEFAULT, 0, 0);
        $gif->setformat("gif");
        $gif->setImageDelay($display_sec);  // 再生時間を設定する
        $gif->setImageIterations(0);        // ループ回数を無限に設定する
        
        $img->destroy();
    }
    
    // イテレータのインデックスを最初のコマに設定
    $gif->setIteratorIndex(0);

    // GIF画像のフォーマット
    $filename = 'test.gif';
    $gif->optimizeimagelayers();
    $gif_file = $gif->getImagesBlob();
    $gif->destroy();

    // GIF画像を保存する
    Storage::disk('public')->put('/gifs/'.$filename, $gif_file);
    
    return redirect()->route('gifs.create')->with('message', 'GIF画像を作成しました。');
}

 

⑤Viewファイルの作成

以下コマンドで、ビューファイルを作成

#ディレクトリの作成
mkdir resources/views/gifs

#ビューファイル作成
touch resources/views/gifs/create.blade.php

resources/views/gifs/create.blade.php

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>GIFCREATOR</title>

        <!-- Styles -->
        <link href="{{ secure_asset('css/styles.css') }}" rel="stylesheet">
    </head>

    <body>
        <div id="main-content">
            <h2>画像・写真からGIFを作成</h2>
            <form id="form_data" action="{{ route('gifs.store') }}" method="POST" enctype="multipart/form-data">
                @csrf
                <div>
                    <span><b>GIFのサイズ </b></span>
                    <label>横:</label>
                    <select name="width" id="select_width" onchange="change_size()">
                        <option value="200">200px</option>
                        <option value="300">300px</option>
                        <option value="400">400px</option>
                        <option value="500">500px</option>
                    </select>
                    <label> 縦:</label>
                    <select name="height" id="select_height" onchange="change_size()">
                        <option value="200">200px</option>
                        <option value="300">300px</option>
                        <option value="400">400px</option>
                        <option value="500">500px</option>
                    </select>
                </div>
                <br/>
                <table border="1" id="table">
                    <th>コマ</th>
                    <th>画像のアップロード</th>
                    <th>プレビュー</th>
                    <th>表示時間</th>
                    <tr id="image_list0" class="img_list">
                        <td>1コマ目</td>
                        <td>
                            <div class="drop-zone">
                                <input type="file" name="image0" class="file-input">
                                <p>ここに画像をドラック&ドロップできます</p>
                            </div>
                        </td>
                        <td>
                            <div class="preview">
                                <img class="preview-img" src="">
                            </div>
                        </td>
                        <td>
                            <select name="image_sec0">
                                <option value="50">0.5秒</option>
                                <option value="100">1.0秒</option>
                                <option value="150">1.5秒</option>
                                <option value="200">2.0秒</option>
                            </select>
                        </td>
                    </tr>
                    <tr id="image_list1" class="img_list">
                        <td>2コマ目</td>
                        <td>
                            <div class="drop-zone">
                                <input type="file" name="image1" class="file-input">
                                <p>ここに画像をドラック&ドロップできます</p>
                            </div>
                        </td>
                        <td>
                            <div class="preview">
                                <img class="preview-img" src="">
                            </div>
                        </td>
                        <td>
                            <select name="image_sec1">
                                <option value="50">0.5秒</option>
                                <option value="100">1.0秒</option>
                                <option value="150">1.5秒</option>
                                <option value="200">2.0秒</option>
                            </select>
                        </td>
                    </tr>
                </table>
                <div>
                    <label onclick="add_img_list()" class="btn">画像の追加</label>
                    <label id="submit-btn" onclick="form_submit()" class="btn inactive">GIFの作成</label>
                </div>
            </form>
            @if(session('message'))
                <div class="message_div">
                    <span class="message">ㇾ{{ session('message') }}</span>
                </div>
                <div class="created_gif">
                    <div>
                        <p>作成されたGIF</p>
                        <img src={{ secure_asset('storage/gifs/test.gif') }} id="gif_image">
                    </div>
                    <div>
                        <a class="download_btn" href="{{ secure_asset('storage/gifs/test.gif') }}" download>ダウンロード</a>
                    </div>
                </div>
            @endif
        </div>
        <script type="text/javascript">
              //ここにJavaScriptを記述
        </script>
    </body>
</html>

 

⑤JavaScriptの記述

ボディの一番後ろにJavaScriptを記述していきます、今回はそのままビューに記述しました。
手動でアップロードする方法と画像をエリアに画像ファイルをドラック&ドロップする方法と両方できるようにしています。また、画像の追加を押すと最後尾の行をコピーして、テーブルの最後尾に追加する処理も実装しました。GIF作成ボタンはファイル未挿入の行がある場合は押せないようになっています。
先に完成図をみるとイメージしやすいかと思います。

JavaScript部

window.onload = function(){
    create_drop_area();
}

function change_size(){
    //画像の幅・高さの取得
    var width = document.getElementById('select_width').value;
    var height = document.getElementById('select_height').value;
    //プレビューの縦横比を更新
    var previewImg = document.querySelectorAll('.preview-img');
    for(let i=0; i<previewImg.length; i++){
        previewImg[i].style.width = (100 * (width/height)) + 'px';
    }
}

//画像の追加ボタンを押したときの処理
function add_img_list(){
    //テーブル要素の取得
    var table = document.getElementById("table");
    //画像の枚数を取得
    var list_count = document.querySelectorAll('.img_list').length;
    //1番下の行を取得
    var latest_list = document.getElementById('image_list' + (list_count-1));
    //新規の行の作成
    var new_list = latest_list.cloneNode(true);
    //複製した行の編集
    new_list.id = 'image_list' + list_count;
    //行を追加
    table.append(new_list);
    //追加した行の子要素を編集
    var file_number = document.querySelector("#image_list" + list_count + "> td:nth-child(1)");
    file_number.textContent = (list_count+1) + 'コマ目';
    var file_input = document.querySelector("#image_list" + list_count + "> td:nth-child(2) > div > input");
    file_input.name = 'image' + list_count;
    var select = document.querySelector("#image_list" + list_count + "> td:nth-child(4) > select");
    select.name = 'image_sec' + list_count;

    create_drop_area();
}

function create_drop_area(){
    // ドラッグ&ドロップ用のエリアを取得
    var dropZone = document.querySelectorAll('.drop-zone');
   var length = dropZone.length;
 
   for(let i=0; i<length; i++){
        // ファイルがドラッグされたときの処理
        dropZone[i].addEventListener('dragover', function(event) {
            // ファイルがドロップされたときのデフォルトの動作を禁止する
            event.preventDefault();
            // エリアをハイライトする
            this.classList.add('highlight');
        });

        // ファイルがドロップされたときの処理
        dropZone[i].addEventListener('drop', function(event) {
            // ファイルがドロップされたときのデフォルトの動作を禁止する
            event.preventDefault();
            // エリアのハイライトを解除する
            this.classList.remove('highlight');

            // ドロップされたファイルを取得
            var file = event.dataTransfer.files[0];

            // プレビュー画像を表示する
            var img = document.querySelectorAll('.preview-img');
            var reader = new FileReader();
            reader.onload = function(e) {
                img[i].src = e.target.result;
            }
            reader.readAsDataURL(file);

            // inputタグにファイルを設定する
            var input = this.querySelector('.file-input');
            var fileList = new DataTransfer();
            fileList.items.add(file);
            input.files = fileList.files;

            // ファイルが追加されたときにフォーム全体をチェックする
            checkAllFilled();
        });

        // ファイルがドラッグから外れたときの処理
        dropZone[i].addEventListener('dragleave', function() {
            // エリアのハイライトを解除する
            this.classList.remove('highlight');
        });
    }
}

function checkAllFilled(){
    //inputタグの要素を取得
    var input_elements = document.querySelectorAll('.file-input');
    var inputFilled = true;
    for(let i=0; i<input_elements.length; i++){
        if(!input_elements[i].value){
            inputFilled = false;
            break;
        }
    }

    if(inputFilled){
        document.getElementById('submit-btn').classList.remove('inactive');
    }else{
        document.getElementById('submit-btn').classList.add('inactive');
    }
}

function form_submit(){
    var input_elements = document.querySelectorAll('.file-input');
    var file_count = 0;
    for(let i=0; i<input_elements.length; i++){
        if(input_elements[i].files.length > 0){
            file_count++;
        }
    }

    if(file_count == input_elements.length){
        var form_data = document.getElementById('form_data');
        form_data.submit();
    }
}

 

結構、コード量が多くなってしまいましたので重要なポイントだけ解説します。

頻繁に記述している「addEventListener」の使い方

「buttun.addEventListener(‘click’, function(){   …  });」みたいに書くことで、’click’イベントが発生したら…の処理を行うようにボタン要素にセットしておくことができます。

簡単に言うと、ボタン要素をクリックされたら…の部分の処理を行うようにセットしておけ。という命令文になります。’click’等のイベントはJavaScript側で用意されていて、
そのイベントをトリガーに…の処理を実行するという意味です。

上記のコードで使用したイベントは以下の3種類です。各画像のドロップエリアに以下のイベントがあった時の処理を埋め込んでいます。
例えば、「dropZone[i].addEventListener('dragover', function(event) { ... }」の部分では、「event.preventDefault();」で通常の処理(リンクのクリック、ページの遷移等)を禁止し、「this.classList.add('highlight');」で自信のクラス名を書き換えています。結果、書き換えたクラス名のCSSが適用されることで、表示が変わったように見えます。

‘dragover’: ドラッグしているオブジェクトをドロップ可能な領域にドラッグしたときに発火。
‘drop’: ドラッグしているオブジェクトをドロップしたときに発火。
‘dragleave’: ドラッグしているオブジェクトがドロップ可能な領域から離れたときに発火。

その他、代表的なものには以下のようなEventもあります。

‘submit’: フォームが送信されるときに発火。
‘keydown’: キーボードのキーが押されたときに発火。
‘load’: 読み込みが完了したときに発火。
‘scroll’: スクロールされたときに発火。
‘mousemove’:マウスが要素の上を動いたら発火。

 

⑥CSSファイルの作成

以下のコマンドをターミナルで打ち、CSSを作成します。

# publicフォルダの配下にcssフォルダを作成
mkdir public/css

# cssフォルダの配下にstyle.cssファイルを作成
touch public/css/styles.css

作成したStyleシートを編集していく

public/css/styles.css

#main-content {
    width:50%;
    margin: 0 auto;
    padding: 10px 30px;
    background-color:#e3e3e3;
}

table {
    margin:0 auto;
}

th {
    padding: 10px 30px;
}

td {
    padding: 10px;
    text-align: center;
    vertical-align: middle;
}

.drop-zone {
    height: 85px;
    width: 300px;
    border: 2px dashed #ccc;
    margin: 10px;
    padding: 10px;
    box-sizing: border-box;
    font-size: 12px;
    color: #aaa;
    cursor: pointer;
}

.drop-zone.highlight {
    border-color: #f00;
}

.drop-zone:focus {
    outline: none;
}

.drop-zone.active {
    border-color: #2196f3;
}

.drop-zone .message {
    margin: 0;
}

.drop-zone .file-list {
    list-style: none;
    margin: 0;
    padding: 0;
    text-align: left;
    max-height: 150px;
    overflow-y: auto;
}

.drop-zone .file-list li {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    margin-bottom: 10px;
    padding: 10px;
    background-color: #eee;
    border-radius: 5px;
    box-sizing: border-box;
}

.drop-zone .file-list li .name {
    flex: 1;
    margin-right: 10px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.drop-zone .file-list li .remove {
    font-size: 14px;
    color: #f44336;
    cursor: pointer;
    transition: color 0.3s ease-in-out;
}

.drop-zone .file-list li .remove:hover {
    color: #d32f2f;
}

.drop-zone .file-list li .preview {
    width: 40px;
    height: 40px;
    margin-left: 10px;
}

.drop-zone .file-list li .preview img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 50%;
}

.drop-zone {
    background-color: #f9f9f9;
    border: 2px dashed #ddd;
    border-radius: 10px;
    padding: 20px;
    margin-bottom: 20px;
}

.drop-zone .dz-message {
    text-align: center;
    font-size: 18px;
    margin-top: 30px;
}

.drop-zone .file-list {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-wrap: wrap;
}

.drop-zone .file-list li {
    width: 85px;
    height: 85px;
    margin-right: 20px;
    margin-bottom: 20px;
    position: relative;
}

.drop-zone .file-list li:last-child {
    margin-right: 0;
}

.drop-zone .file-list li .preview {
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: 50%;
    position: relative;
}

.drop-zone .file-list li .preview img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 50%;
}

.preview-img {
    display: block;
    width: 100px;
    height: 100px;
    margin: auto;
    border:solid 1px gray;
}

select {
    padding: 5px 10px;
    font-size: 16px;
}

.btn {
    display: block;
    width: 80%;
    margin: 20px auto;
    padding: 10px 30px;
    border-radius: 9999px;
    border: none;
    background-color: pink;
    color: white;
    text-align: center;
    font-size: 16px;
    cursor: pointer;
}

.inactive {
    background-color: #f3f3f3;
    color: #e3e3e3;
}


.btn:hover {
    opacity: 0.75;
}

.message_div {
    border-radius: 10px;
    background-color: #ceeee8;
    text-align: center;
}

.message {
    color: #0dab8f;
    font-weight: bold;
    font-size: 16px;
}

.message_div {
    margin-bottom: 20px;
}

.created_gif {
    display: flex;
    justify-content: center;
    align-items: center;
}

.created_gif p {
margin-right: 20px;
}

.download_btn {
    width: 40%;
    margin: 0 50px;
    padding: 10px 20px;
    border-radius: 9999px;
    background-color: lightblue;
    color: white;
    text-decoration: none;
}

 

完成図

上記手順にて、下図のような画面が作成されました。

以下は、実際に作成したアプリを動かしている様子になります。こちらも画面をキャプチャーして、それを繋げたGIFアニメになっています。

追記

作成したアプリをHerokuにデプロイした際に、Imagickがherokuで使用できず、少し苦労したので解消するための手順を書きます。
一般にHerokuプッシュ時にライブラリが入らなかったときは以下の手順で解消していきます。(私も今回デプロイが上手くいかず色々調べて得た知識です。)
 ①heroku buildpacks:add –index 1 heroku-community/apt を実行
 ②Aptfileをプロジェクト直下に生成し、使用したいパッケージ名を記載
 ③composer.jsonのrequire内にパッケージを追加

上記手順をImagickの場合、どのように実行していったかを以下に記します。

①heroku buildpacks:add –index 1 heroku-community/apt を実行

ターミナルで以下のコマンドを打ちます。node.jsのパッケージインストール時に、index1を「heroku buildpacks:add –index 1 heroku/nodejs」で使用していたので、index2を使用しました。index2を既に使用している場合は、index3を使って下さい。
このコマンドで、「apt-get」というツールをHerokuにインストールしています。「apt-get」というのは簡単に言うと必要なパッケージやライブラリをアプリにインストール、アップグレード、削除するためのツールです。

heroku buildpacks:add --index 2 heroku-community/apt

②Aptfileをプロジェクト直下に生成し、使用したいパッケージ名を記載

プロジェクトの直下のフォルダに移動して、Aptfileを作成します。Aptfileには「imagemagick」と記載します。Aptfileに記載する内容はパッケージごとに確認が必要です。

echo "imagemagick" > Aptfile

③composer.jsonのrequire内にパッケージを追加

下のコードの「"ext-imagick": "*"」の部分を追記します。直前の行の末尾にコンマ「,」を付け忘れないように注意してください。
②,③を行うとherokuのアプリをデプロイしたときに、もし「imagemagick」がインストールされてなかったら自動で「imagemagick」をインストールしなさい。という命令を書いているといったイメージです。

"require": {
    "php": "^8.0.2",
    "guzzlehttp/guzzle": "^7.2",
    "intervention/image": "^2.7",
    "laravel/framework": "^9.19",
    "laravel/sanctum": "^3.0",
    "laravel/tinker": "^2.7",
    "ext-imagick": "*" //追加
},