🐡

Firebase Storage の画像を、API 1つ作るだけでサムネイル用に圧縮表示する(Node.js)

2023/04/06に公開

一覧表示で圧縮表示するやつ

環境

  • Firebase
    • Cloud Functions
    • Cloud Storage
  • Flutter

がっつり参考

image_cdn.ts

ソースコード

前までやってた実装

オリジナルの画像と、圧縮された画像の二種類を保存する。

final file = state.file;
final compressedFile = await FlutterImageCompress.compressWithFile(
    file.path,
    minWidth: 400,
    minHeight: 400,
  );

await storage.save(file, path: "users/$uid/icon.png");
await storage.save(compressedFile, path: "users/$uid/icon_compressed.png");

解決方法

Cloud Functions のリクエストで、圧縮した画像 URL を返すパスを提供する。

ImageCdn.ts
import express from "express";
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import sharp from "sharp";

const MAX_WIDTH = 1080;
const MAX_HEIGHT = 1080;

interface Size {
  width: number;
  height: number;
}

const app = express();
const router = express.Router();
const cacheControl = "public, max-age=3600, s-maxage=36000";

const parseSize = (size: string): Size => {
  let width, height;
  if (size.indexOf(",") !== -1) {
    [width, height] = size.split(",");
  } else if (size.indexOf("x") !== -1) {
    [width, height] = size.split("x");
  } else {
    [width, height] = [size, size];
  }
  width = parseInt(width, 10) || MAX_WIDTH;
  height = parseInt(height, 10) || MAX_HEIGHT;
  width = Math.min(width, MAX_WIDTH);
  height = Math.min(height, MAX_HEIGHT);
  return { width, height };
};

router.get("/s/:size/*", corsSetting, async (req, res, next) => {
  try {
    res.set("Cache-Control", cacheControl);
    const { size } = req.params;
    const targetSize = parseSize(size);
    const storagePath = req.path.split(`/s/${size}/`)[1];

    if (
      storagePath.includes(".jpg") ||
      storagePath.includes(".jpeg") ||
      storagePath.includes(".png") ||
      storagePath.includes(".JPG") ||
      storagePath.includes(".JPEG") ||
      storagePath.includes(".PNG")
    ) {

      // Cloud Storage から data の取得
      const bucket = admin.storage().bucket();
      const response = await bucket.file(storagePath).download();
      const originalImage = response[0];

      // Response の Content-Type を jpg or png にする
      if (storagePath.includes(".jpg") || storagePath.includes(".jpeg") || storagePath.includes(".JPEG")) {
        res.type("jpg");
      }

      if (storagePath.includes(".png") || storagePath.includes(".PNG")) {
        res.type("png");
      }

      // sharp ライブラリを使用してリサイズ
      const sharpImage = sharp(originalImage);
      const { orientation } = await sharpImage.metadata();

      const resizedImage = await sharpImage
        // 縦横比を保持
        // これがないと、resize() によって縦横比がなんかおかしくなってしまう
        .withMetadata({ orientation })
        .resize({
          width: targetSize.width,
          height: targetSize.height,
        })
        .toBuffer();

      res.status(200).send(resizedImage);
    }
  } catch (error) {
    functions.logger.error(error);
    next(error);
  }
});

router.use((req, res, next) => {
  const err = new Error("Not Found");
  next(err);
});

// error handler
router.use((req, res, next) => {
  res.status(400).send("Bad Request");
});

app.use("/", router);

export const cdn = functions
  .region("asia-northeast1")
  .runWith({
    memory: "2GB",
  })
  .https.onRequest(app);

Flutter 側

String getFunctionsUrl(String path) {
  // url の構成
  // https://firebase.google.com/docs/functions/http-events?hl=ja#invoke_an_http_function
  return Uri.https('asia-northeast1-${projectId}.cloudfunctions.net', path).toString();
}

String getComppresedImageUrl({required int size, required String storagePath})) {
  return getFunctionsUrl('cdn/s/$size/$storagePath');
}

class ThumbnailImage extends StatelessWidget {
  ThumbnailImage({
    this.storagePath,
    super.key,
  });

  final String storagePath;

  
  Widget build(BuildContext context) {
    final url = getFunctionsUrl(
      size: 400,
      storagePath: storagePath,
    );

    return Image.network(url);
  }
}

web で使うために

ImageCdn.ts
import express from "express";
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import sharp from "sharp";
+ import * as cors from "cors";

const MAX_WIDTH = 1080;
const MAX_HEIGHT = 1080;

interface Size {
  width: number;
  height: number;
}

const app = express();
const router = express.Router();
const cacheControl = "public, max-age=3600, s-maxage=36000";

+ const corsSetting = cors.default({
+   origin: (origin, callback) => {
+     if (
+       !origin ||
+       origin.startsWith("http://localhost") ||
+       origin?.startsWith("https://hoge")
+     ) {
+       callback(null, true);
+       return;
+     }
+     callback(new Error("Not Allowed Origin"));
+   },
+   methods: "GET, POST, OPTIONS",
+   preflightContinue: true,
+   credentials: true,
+   optionsSuccessStatus: 200,
+ });

+ const acceptCrossOrigin: express.RequestHandler = (req, res) => {
+   res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "");
+   res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");

+   res.status(200).send();
+ };

const parseSize = (size: string): Size => {
  let width, height;
  if (size.indexOf(",") !== -1) {
    [width, height] = size.split(",");
  } else if (size.indexOf("x") !== -1) {
    [width, height] = size.split("x");
  } else {
    [width, height] = [size, size];
  }
  width = parseInt(width, 10) || MAX_WIDTH;
  height = parseInt(height, 10) || MAX_HEIGHT;
  width = Math.min(width, MAX_WIDTH);
  height = Math.min(height, MAX_HEIGHT);
  return { width, height };
};

router.get("/s/:size/*", corsSetting, async (req, res, next) => {
  try {
    res.set("Cache-Control", cacheControl);
    const { size } = req.params;
    const targetSize = parseSize(size);
    const storagePath = req.path.split(`/s/${size}/`)[1];

    if (
      storagePath.includes(".jpg") ||
      storagePath.includes(".jpeg") ||
      storagePath.includes(".png") ||
      storagePath.includes(".JPG") ||
      storagePath.includes(".JPEG") ||
      storagePath.includes(".PNG")
    ) {

      // Cloud Storage から data の取得
      const bucket = admin.storage().bucket();
      const response = await bucket.file(storagePath).download();
      const originalImage = response[0];

      // Response の Content-Type を jpg or png にする
      if (storagePath.includes(".jpg") || storagePath.includes(".jpeg") || storagePath.includes(".JPEG")) {
        res.type("jpg");
      }

      if (storagePath.includes(".png") || storagePath.includes(".PNG")) {
        res.type("png");
      }

      // sharp ライブラリを使用してリサイズ
      const sharpImage = sharp(originalImage);
      const { orientation } = await sharpImage.metadata();

      const resizedImage = await sharpImage
        // 縦横比を保持
        // これがないと、resize() によって縦横比がなんかおかしくなってしまう
        .withMetadata({ orientation })
        .resize({
          width: targetSize.width,
          height: targetSize.height,
        })
        .toBuffer();

      res.status(200).send(resizedImage);
    }
  } catch (error) {
    functions.logger.error(error);
    next(error);
  }
});

router.use((req, res, next) => {
  const err = new Error("Not Found");
  next(err);
});

// error handler
router.use((req, res, next) => {
  res.status(400).send("Bad Request");
});

app.use("/", router);

+ app.options("*", corsSetting, acceptCrossOrigin);

export const cdn = functions
  .region("asia-northeast1")
  .runWith({
    memory: "2GB",
  })
  .https.onRequest(app);

Discussion