Chapter 15

Storage:ファイルの圧縮

masalib
masalib
2020.12.26に更新

Firebaseを無料で使っていくためにはファイルサイズは小さくした方がいいです。
Functionsで画像の圧縮ができるのですが、有料プランじゃないとFunctionsが実行できません。画面側で圧縮できる画像圧縮ライブラリー(blueimp-load-image)を導入して対応した。

やりたい事

  • 画像の圧縮してファイルをアップする。

私の場合は、画像をクロップしてから圧縮するという通常じゃないパターンです。

処理としては以下の流れになる

  1. ローカル画像(普通の画像)
  2. クロップ画像(data_url形式のバイナリーデータ)
  3. 画像圧縮(blob形式のバイナリーデータ)
  4. Firebase Storage(普通の画像)

もし通常パターンの圧縮を知りたい場合は参考にしたページをみてください

  1. ローカル画像(普通の画像)
  2. 画像圧縮(blob形式のバイナリーデータ)
  3. Firebase Storage(普通の画像)

https://zenn.dev/tiwu_dev/articles/72f8d7de22b164

画像圧縮ライブラリーのインストール

$ npm install blueimp-load-image

アッププログラムの修正

前回のソースを修正します

/src/components/UpLoadTest.js
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { storage } from "../firebase";
import {
  Typography,
  Box,
  Button,
  LinearProgress,
  Modal,
  Backdrop
} from "@material-ui/core";

import Cropper from "react-cropper";
import "cropperjs/dist/cropper.css";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
+ import loadImage from "blueimp-load-image"; //画像圧縮

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    modal: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center"
    },
    paper: {
      backgroundColor: theme.palette.background.paper,
      border: "2px solid #000",
      boxShadow: theme.shadows[5],
      padding: theme.spacing(2, 4, 3)
    }
  })
);

const UpLoadTest = () => {
  const [image, setImage] = useState("");
  const [imageName, setImageName] = useState("");
  const [imageUrl, setImageUrl] = useState("");
  const [error, setError] = useState("");
  const [progress, setProgress] = useState(100);

  const classes = useStyles(); //Material-ui
  const [cropper, setCropper] = useState();
  const [open, setOpen] = React.useState(false);
  const [openCircularProgress, setOpenCircularProgress] = React.useState(false); //処理中みたいモーダル

  const handleImage = (e) => {
    setError("");
    try {
      const image = e.target.files[0];
      setImageName(image.name); //アップロード時のファイル名で使用
      e.preventDefault();
      let files;
      if (e.dataTransfer) {
        files = e.dataTransfer.files;
      } else if (e.target) {
        files = e.target.files;
      }
      const reader = new FileReader();
      reader.onload = () => {
        setImage(reader.result);
      };
      reader.readAsDataURL(files[0]);
      setOpen(true);
      e.target.value = null; //ファイル選択された内容をクリアする(クリアしないと同じファイルが編集できない)
    } catch (e) {
      e.target.value = null;
      setError("画像の切り取りをキャンセルまたは失敗しました");
      setOpen(false);
    }
  };

  const getCropData = async (e) => {
+     console.log(
+       "if 「Potential infinite loop: exceeded 10001 iterations」is error , create sandbox.config.json"
+     );
    e.preventDefault();
    if (typeof cropper !== "undefined") {
      //デフォルトのPNGはファイルサイズが大きいのでjpegにする
      let imagedata = await cropper.getCroppedCanvas().toDataURL("image/jpeg");
      //console.log(imagedata); //バイナリーが見たい人は出力すると見れます


-       // アップロード処理
-       console.log("アップロード処理");
-       const storageRef = storage.ref("images/test/"); //どのフォルダの配下に入れるかを設定
-       const imagesRef = storageRef.child(imageName); //ファイル名

-       console.log("ファイルをアップする行為");
-       const upLoadTask = imagesRef.putString(imagedata, "data_url");

-       console.log("タスク実行前");
-       setOpenCircularProgress(true);

-       upLoadTask.on(
-         "state_changed",
-         (snapshot) => {
-           console.log("snapshot", snapshot);
-           const percent =
-             (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
-           console.log(percent + "% done");
-           setProgress(percent);
-         },
-         (error) => {
-           console.log("err", error);
-           setError("ファイルアップに失敗しました。" + error);
-           setProgress(100); //実行中のバーを消す
-           setOpen(false);
-           setOpenCircularProgress(false);
-         },
-         () => {
-           setImageUrl("");
-           upLoadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
-             console.log("File available at", downloadURL);
-             setImageUrl(downloadURL);
-             setOpen(false);
-             setOpenCircularProgress(false);
-           });
-         }
-       );
+      //data_url => Blob
+       let byteString = atob(imagedata.split(",")[1]);
+       let mimeType = imagedata.match(/(:)([a-z\/]+)(;)/)[2]; //UnnecessaryのWARNINGがでるが無視するしかない

+       for (
+         var i = 0, l = byteString.length, content = new Uint8Array(l);
+         l > i;
+         i++
+       ) {
+         content[i] = byteString.charCodeAt(i);
+       }

+       let blob = new Blob([content], {
+         type: mimeType
+       });

+       const canvas = await loadImage(blob, {
+         maxWidth: 960,
+         canvas: true
+       });

+       canvas.image.toBlob((blob) => {
+         // アップロード処理
+         console.log("アップロード処理");
+         const storageRef = storage.ref("images/test/"); //どのフォルダの配下に入れるかを設定
+         const imagesRef = storageRef.child(imageName); //ファイル名

+         console.log("ファイルをアップする行為");
+         //const upLoadTask = imagesRef.putString(imagedata, "data_url");
+         const upLoadTask = imagesRef.put(blob);

+         console.log("タスク実行前");
+         setOpenCircularProgress(true);

+         upLoadTask.on(
+           "state_changed",
+           (snapshot) => {
+             console.log("snapshot", snapshot);
+             const percent =
+               (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
+             console.log(percent + "% done");
+             setProgress(percent);
+           },
+           (error) => {
+             console.log("err", error);
+             setError("ファイルアップに失敗しました。" + error);
+             setProgress(100); //実行中のバーを消す
+             setOpen(false);
+             setOpenCircularProgress(false);
+           },
+           () => {
+             setImageUrl("");
+             upLoadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
+               console.log("File available at", downloadURL);
+               setImageUrl(downloadURL);
+               setOpen(false);
+               setOpenCircularProgress(false);
+             });
+           }
+         );
+       }, mimeType);
    }
    return;
  };

  const handleClose = () => {
    setOpen(false);
  };
  const handleCircularProgressClose = () => {
    setOpenCircularProgress(false);
  };

  return (
    <div>
      upload
      {error && <div variant="danger">{error}</div>}
      <h2>
        <Link to="/Dashboard">Dashboard</Link>
      </h2>
      <form>
        <input type="file" onChange={handleImage} />
      </form>
      {imageUrl && (
        <div>
          <img width="400px" src={imageUrl} alt="uploaded" />
        </div>
      )}
      <Modal
        aria-labelledby="transition-modal-title"
        aria-describedby="transition-modal-description"
        className={classes.modal}
        open={open}
        onClose={handleClose}
        closeAfterTransition
        BackdropComponent={Backdrop}
        BackdropProps={{
          timeout: 500
        }}
      >
        <div className={classes.paper}>
          <h2 id="transition-modal-title" style={{ textAlign: "center" }}>
            画像の切り抜き
          </h2>
          <Cropper
            style={{ height: 400, width: "100%" }}
            initialAspectRatio={1}
            aspectRatio={1}
            preview=".img-preview"
            src={image}
            viewMode={1}
            guides={true}
            minCropBoxHeight={10}
            minCropBoxWidth={10}
            background={false}
            responsive={true}
            autoCropArea={1}
            checkOrientation={false}
            onInitialized={(instance) => {
              setCropper(instance);
            }}
          />
          <Button
            variant="contained"
            size="large"
            fullWidth
            color="primary"
            className={classes.updateProfileBtn}
            onClick={getCropData}
          >
            選択範囲で反映
          </Button>

          <Button
            variant="contained"
            size="large"
            fullWidth
            className={classes.updateProfileBtn}
            onClick={handleClose}
          >
            キャンセル
          </Button>
        </div>
      </Modal>
      <Modal
        className={classes.modal}
        open={openCircularProgress}
        onClose={handleCircularProgressClose}
        closeAfterTransition
        BackdropComponent={Backdrop}
        BackdropProps={{
          timeout: 500
        }}
      >
        <div className={classes.paper} style={{ textAlign: "center" }}>
          <div>現在処理中です。</div>
          {progress !== 100 && <LinearProgressWithLabel value={progress} />}
        </div>
      </Modal>
    </div>
  );
};

function LinearProgressWithLabel(props) {
  return (
    <Box display="flex" alignItems="center">
      <Box width="100%" mr={1}>
        <LinearProgress variant="determinate" {...props} />
      </Box>
      <Box minWidth={35}>
        <Typography variant="body2" color="textSecondary">{`${Math.round(
          props.value
        )}%`}</Typography>
      </Box>
    </Box>
  );
}
export default UpLoadTest;

結果

元画像サイズ cropのみ crop + 圧縮
203K 156K 156K
1138K 548K 140K
4633K 1780K 119K

サイズが小さいのは圧縮が不要だけど判別するには手間がかかるのでやらない方針にします。
今どきの画像はありえないぐらいでかいので圧縮は必須だと思います。

できていない事

let mimeType = imagedata.match( /(:)([a-z\/]+)(;)/ )[2] ; //UnnecessaryのWARNINGがでるが無視するしかない

上記のコードだとWARNINGが発生する・・・よくわからず放置している

終了時点のソース

  • Guestログインしたい人は以下のアカウントを使ってください
    ID:test@test.com
    pass:test1234

参考URL

https://lab.syncer.jp/Web/JavaScript/Snippet/26/

https://github.com/blueimp/JavaScript-Load-Image