Closed6

Remix+CloudflareでWebサイトを作る 49(PNG,JPGをWebPに変換)

saneatsusaneatsu

【2025-01-06】PNG,JPGをWebPに変換

背景

https://zenn.dev/link/comments/15f6b0af9c526a

AVIFの変換は色々試したけどできなかった。
ではWebPならどうか?

試す

https://www.npmjs.com/package/webp-converter-browser

最終更新が2年前だけど、TS対応してるっぽいし12.8KBなので良さそう。
まずはREADMEのまんまのコードを書いてみる。

const arrayBuffer = await file.arrayBuffer();
const webpBlob = await arrayBufferToWebP(jpgArrayBuffer)

エラー

arrayBufferToWebP 時に URL.createObjectURL() is not implemented というエラーが発生。
サーバー側で行っていたこの処理をフロント側で行うようにしてみたら解決。

完成したコード

アップロード箇所だけ抜粋する。

クライアント側

route.tsx
import { arrayBufferToWebP } from "webp-converter-browser";

async function uploadFile(file: File) {
  const arrayBuffer = await file.arrayBuffer();
  const webpBlob = await arrayBufferToWebP(arrayBuffer); // qualityはデフォルトで75
  const webpFile = new File(
    [webpBlob],
    file.name.replace(/\.[^/.]+$/, ".webp"),
    {
      type: "image/webp",
    },
  );

  const formData = new FormData();
  formData.append("file", webpFile);

  fetcher.submit(formData, {
    action: "/r2-upload",
    method: "POST",
    encType: "multipart/form-data",
  });
}

サーバー側

上2つは拡張子を何もつけていなかったので image/png と判定されている。

r2-upload.tsx
import { parseWithZod } from "@conform-to/zod";
import { z } from "zod";

import type { ActionFunctionArgs } from "@remix-run/cloudflare";
import { errorJson, successJson } from "~/lib/action-response";

const schema = z.object({
  file: z
    .instanceof(File, { message: "ファイルを選択してください" })
    .transform((file) => file)
    .refine((file) => file.size < 1024 * 1024, {
      message: "ファイルサイズは最大1MBです",
    }),
});

export type R2UploadResponse = {
  url: string;
};

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema });
  const lastResult = submission.reply();

  if (submission.status !== "success") {
    return errorJson({ error: "入力した情報が正しくありません", lastResult });
  }

  try {
    const now = new Date();
    now.setHours(now.getHours() + 9); // 日本時間に変換
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");

    const key = `${year}${month}${day}/${crypto.randomUUID()}.webp`; // .webp 必須!
    const webpFile = submission.value.file;

    // R2にアップロード
    const r2Object = await context.cloudflare.env.BUCKET.put(key, webpFile);
    if (r2Object === null) {
      throw new Error("画像のアップロードに失敗しました");
    }

    const url = `${context.cloudflare.env.R2_DOMAIN}/${r2Object.key}`;

    return successJson<R2UploadResponse>({ lastResult, data: { url } });
  } catch (error: unknown) {
    return errorJson({ error, lastResult });
  }
}

サイズはどれくらい削減された?

クオリティはデフォルトの75を使用しているが、サイズが7119 Byte → 1814 Byteになっていることがわかる。
348Kあるものは94Kになった。
こんな簡単なコードで75%近く削減されている。すごい。

メモ

https://zenn.dev/xx_suzuki/articles/sharp-verification

より小さいAVIFを使いたかったけどサイズが小さくなるのを見ると満足できる。

saneatsusaneatsu

【2025-01-07】WebPに変換してリサイズも行うライブラリを使ってみる

背景

WebPに変換できるようになった。次は画像のサイズ自体が大きすぎる場合に縦横比を保ったままリサイズするようにしたい。

直にコードを書いてもいいけどライブラリでよりいい感じなのないかな〜と探していたら以下を発見。

https://www.npmjs.com/package/image-resize-compress

1つ上のScrapで使ったwebp-converter-browserの12.8KBよりは少し大きい(18.4KB)が全然許容できる範囲。
最初のリリースは5年前で、その後全然更新していなかったようだけど先月ちょっとメンテしてる。

しかもこれWebPへの変換してくれる。

コード

画像が大きい場合、縦横比を保った状態でリサイズしてWebPへ変換するコード。

import { fromBlob } from "image-resize-compress";

const MAX_WIDTH = 1280
const MAX_HEIGHT = 720

/**
 * 最大の高さ・幅を超えた場合は、アスペクト比を保持したままリサイズする高さ・幅を計算して取得する
 * 超えていない場合は、元の高さ・幅をそのまま返す
 */
async function getResizedImageDimensions(file: File) {
  // 変換後の画像サイズを取得
  const image = await new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = URL.createObjectURL(file);
  });

  // デバッグ用
  const beforeSizeInKB = (file.size / 1024).toFixed(1);
  console.log(
    "Before: ",
    `width=${image.width.toFixed(1)}, height=${image.height.toFixed(1)}, size=${beforeSizeInKB} KB`,
  );

  let width = image.width;
  let height = image.height;
  if (width > MAX_WIDTH || height > MAX_HEIGHT) {
    // 画像のサイズが大きい場合はリサイズ
    const aspectRatio = width / height;
    if (aspectRatio > 1) {
      // 横長の場合
      width = MAX_WIDTH;
      height = MAX_WIDTH / aspectRatio;
    } else {
      // 縦長の場合
      height = MAX_HEIGHT;
      width = MAX_HEIGHT * aspectRatio;
    }
  }

  return { width, height };
}

async function uploadFile(file: File) {
  // 変換後の画像サイズを取得
  const { width, height } = await getResizedImageDimensions(file);

  // 画像をリサイズして、WebPに変換
  const blob = new Blob([file], { type: file.type });
  const webpBlob = await fromBlob(blob, 75, width, height, "webp");
  const webpFile = new File(
    [webpBlob],
    file.name.replace(/\.[^/.]+$/, ".webp"),
    { type: "image/webp" },
  );

  // デバッグ用
  const afterSizeInKB = (webpFile.size / 1024).toFixed(1);
  console.log(
    "After: ",
    `width=${width.toFixed(1)}, height=${height.toFixed(1)}, size=${afterSizeInKB} KB`,
  );

  // 画像をR2にアップロード
  // アップロード処理を書く  ; 
}
saneatsusaneatsu

どうもサイズの減り方がWebPに変換する70%よりも大きくならないなと思ったら、fromBlob()はWebPにできるがリサイズはできていないっぽい?

R2にアップロードされた画像を見るとIntrinsic sizeが変わっていなかった。

ただし、このライブラリのでもページではリサイズできているけどなんでだ。
https://alefduarte.github.io/image-resize-compress-demo/

saneatsusaneatsu

両方の引数が数値で設定されている場合でも、元画像のサイズがそのまま返されるバグある。
簡単なコードだし一旦コードをコピペしてバグ直して使ってみるとちゃんとリサイズされてサイズもめちゃくちゃ削減できた。
ライブラリ使わないのでコードサイズも小さくなって色々良かったな?

Issueは後で作成しにいこう。

saneatsusaneatsu

アプリ上で扱える最大サイズの画像4枚とその他画像が3枚あるページだけどなかなか良いスコアなんじゃないだろうか。

もっと描画する内容がもりもりになったら再度計測しよう。
一旦満足。

このスクラップは2025/01/08にクローズされました