アップロード前に画像縮小・圧縮・切抜きしたい

ご無沙汰しております、沖縄チームのキリです。

個人目標でflatterを使ったアプリ開発を掲げたため、その様子をブログ記事としてまとめていこうと思っていたのですが、昨年末頃は業務と個人的な案件によりどうにもまとまった時間を作れなかったため更新が停滞していました。これからこまめに記事を上げたい所存です…。

さて表題についてですが、昨今はスマホのカメラ性能の向上によりスマホで撮影した画像のファイルサイズが非常に大きくなっている場合が多いですね。PHPでの画像圧縮処理といえばバックエンドで受け取った画像をGDやImageMagickといったライブラリを用いて圧縮するのが基本だったかと思います。しかし非常に解像度の高い画像をGDやImageMagickで扱うとなるとサーバーに多大な負荷がかかり処理がタイムアウトしてしまうことも頻発します。アップロードサイズ上限を設定してユーザーひとりひとりに圧縮済みの画像を用意してもらうというのもユーザー体験を損ねてしまいます。

そういうときに役立つ2つのJavaScriptライブラリを紹介したいと思います。まず1つはCompressor.js、もう1つがCropper.jsというライブラリです。どちらも開発者は同じようです。以下、それぞれ簡単に説明します。(名前の通りなのでわかりやすいですね)

Compressor.js

https://fengyuanchen.github.io/compressorjs/

こちらは画像の縮小、圧縮をフロントエンドで行うライブラリです。オプションにより一定のファイルサイズや縦幅・横幅を超えたら圧縮・縮小させたり固定のサイズに縮小させたりすることが出来ます。個人的にはmaxWidthとconvertSizeの2つのみ設定しておけば十分かと思います。

Cropper.js

https://fengyuanchen.github.io/cropperjs/

こちらは多機能な画像切り抜きライブラリです。切り抜く範囲をリアルタイムにプレビュー表示したり画像を回転・反転したり、円形にくり抜いたりもできるようです(正方形にくり抜いてCSSで円形に表示する方が使い勝手が良いような…)。いずれもどんなことができるかは公式サイトのExamplesを見ていただきたいです。

Vue3での実装

私の好みによりLaravel + Vue3での実装を行っていきます。これらのライブラリ、Vue3で実装している例をなかなか見つけられず苦労しました…。

まずはメインのコンポーネントです。

<script setup lang="ts">
import Compressor from "compressorjs";
import { computed } from "vue";
import { ref } from "vue";
import 'cropperjs/dist/cropper.css';
import SimpleCropper from '@/Components/SimpleCropper.vue';

// 画像選択時に圧縮した画像のデータURL
const compressed = ref<string>(null);
// アップロードするバイナリデータ
const image = ref<Blob>(null);
// プレビュー用のデータURL
const preview = computed<string>(() => image.value ? URL.createObjectURL(image.value) : null);

// 画像選択時の処理
function onChange(e: Event) {
    const target = e.target as HTMLInputElement;
    const file = target.files.item(0);

    // input[type=file]の選択された画像を解除
    target.value = '';

    // 画像ファイルが選択されていなければ何もしない
    if (!file) {
        compressed.value = null;
        return;
    }

    // 画像選択時に縦横1000pxに収まるサイズに画像を縮小する
    new Compressor(file, {
        maxWidth: 1000,
        maxHeight: 1000,
        success(result) {
            compressed.value = URL.createObjectURL(result);
        },
        error(err) {
            console.log(err.message);
        },
    });
}

// 画像切り抜き確定後の処理
function onCropped(canvas: HTMLCanvasElement) {
    // 切り抜き前画像をクリア
    compressed.value = null;

    // キャンバスデータをBlobに変換
    canvas.toBlob((blob) => {
        // 画像を圧縮
        new Compressor(blob, {
            convertSize: 200000, // 200KBを超えた場合JPEGに変換する
            success(result) {
                // アップロードするデータにセット
                image.value = result;
            },
        });
    });
}

// 画像アップロード処理
function submit() {
    const formData = new FormData();
    formData.append('image', image.value);
    fetch('/receive', {
        body: formData,
        method: 'POST',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
        }
    }).then(res => {
        alert('画像のアップロードが完了しました');
        image.value = null;
    });
}
</script>

<template>
    <h1 class="h1">画像圧縮してからアップロード</h1>
    <div class="my-4">
        <input type="file" name="image" id="image" @change="onChange">
    </div>
    <div v-if="compressed" class="my-4">
        <SimpleCropper :image="compressed" @cropped="onCropped" />
    </div>
    <div v-if="preview" class="my-4">
        <div>
            <img :src="preview" alt="" class="preview">
        </div>
        <div class="space-x-4">
            <button type="button" @click="submit" class="btn">送信</button>
            <button type="button" @click="image = null" class="btn">リセット</button>
        </div>
    </div>
</template>

画像切り抜きのコンポーネントは次の通りです。

<script setup lang="ts">
import Cropper from 'cropperjs';
import { onMounted } from 'vue';
import { ref } from 'vue';

defineProps<{
    image: string; // 画像URL
}>();

const emit = defineEmits<{
    (e: 'cropped', data: HTMLCanvasElement): void; // 切抜き確定イベントを定義
}>();

// Cropper.jsに渡すimg要素
const imgElement = ref<HTMLImageElement>();
// Cropper.jsのインスタンス
const cropper = ref<Cropper>(null);
// 切抜き時のプレビュー用要素
const preview = ref<HTMLElement>();

// 切抜き確定処理
function onFinish(e: Event) {
    e.preventDefault();
    // canvas要素を取得
    const canvas = cropper.value.getCroppedCanvas();
    // croppedイベントを発火 canvas要素を親コンポーネントに渡す
    emit('cropped', canvas);
}

// DOM要素を扱うためマウント後に実行
onMounted(() => {
    cropper.value = new Cropper(imgElement.value, {
        viewMode: 3,
        preview: preview.value,
    });
});
</script>

<template>
    <div>
        <div>
            <img ref="imgElement" :src="image" alt="" />
        </div>
        <div ref="preview" class="w-48 h-48 overflow-hidden"/>
        <div>
            <button type="button" @click="onFinish" class="btn">確定</button>
        </div>
    </div>
</template>

さて画像のアップロードをしてみます。用意した画像は素材サイトから探してきた6000×3376ピクセルで9.58MBの巨大なjpg画像です。スマホで解像度上げて撮影するとこのくらいあるいはもっと大きなサイズになりますね…画像選択時の状態はこのような表示です。

まずは切抜きはせずに送信してみます。

受信側はLaravelの$request->dd()メソッドで受け取ったリクエストをそのまま表示するようにしています。

jpeg画像に変換され、容量は47KBほどに圧縮されました。

それでは次にねこちゃんの顔を切り抜いて送信してみます。

このようになりました。

145KBほどのpng画像として送信されていますね。convertSizeを200,000に設定しているのでそれを超えない場合はpng画像が作成されることがわかりました。色々と試したところconvertSizeを超えない場合は基本的にpng画像として扱われるようですが、オプション次第で常にjpeg画像に変換することもできるかも?

というわけで今回はフロントエンドで画像を縮小・圧縮、切抜きをすることが可能になる2つのライブラリを紹介しました。画像アップロード機能を開発する場合は是非お試しください。