🐡
Firebase Storage の画像を、API 1つ作るだけでサムネイル用に圧縮表示する(Node.js)
一覧表示で圧縮表示するやつ
環境
- Firebase
- Cloud Functions
- Cloud Storage
- Flutter
がっつり参考
ソースコード
前までやってた実装
オリジナルの画像と、圧縮された画像の二種類を保存する。
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