97
80

【60分でマスター】Reactで画像ジェネレーターを作ろう!【TypeScript/Supabase/StabilityAI/TailwindCSS】

Last updated at Posted at 2024-09-15

BUILD FIRST APP.png

はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。
アプリを作る上で事前に手札が多いほうが、実現できるアイデアの幅も広くなります。

今回は「AIによる画像生成」「ストレージへの保存」をメインとした画像ジェネレーターをReactで実装するアプリケーションを作成していきましょう。

Videotogif (2).gif

今回はNext.jsの開発元であるVeceltとパートナー強化がされた「Supabase」を利用して素早く実装します。
今後Supabaseはより選択肢になる可能性が高くなる素晴らしいBaaSです。触ったことない方でもわかるように丁寧に解説しています。

動画で更に詳しく解説

この記事ではReact自体の解説は細かくしておりません。
もしReactやTypeScriptに不安がある方、やったことがない方は以下の動画をみてください!

ハンズオンの対象者

  • Reactの基本がなんとなくわかる
  • ハンズオンで学びたい人
  • Supabaseを使ってみたい人
  • AIを自分でも使ってみたい人

1. Reactの環境構築

ReactとTailwindCSSが実行できる環境を用意してきましょう。
Node.jsが実行できる環境がお手元にない方は以下を参考にそれぞれのOSにあった方法でインストールしてください!

インストールができたことを以下の確認で確認してください

❯ node -v
v18.17.0

Reactのプロジェクトを構築します。
今回はViteを利用していきます。Viteは次世代のビルドツールで早くて無駄のない環境を提供してくれます。

❯ npm create vite
Need to install the following packages:
  create-vite@5.5.2
Ok to proceed? (y) y
✔ Project name: … image-generator
✔ Select a framework: › React
✔ Select a variant: › TypeScript

$ cd image-generator/
$ npm i
$ npm run dev

http://localhost:5173にアクセスして以下の画面が表示されればReact環境が無事できています。

image.png

次にTailwindCSSを導入していきます。

$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p

プロジェクトをVSCodeで開いてtailwind.config.jsをいかに変えます

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

src/index.cssを変更します

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

src/App.tsxを変更してスタイルがあたるかをチェックします

src/App.tsx
function App() {
  return (
    <>
      <div>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          Button
        </button>
      </div>
    </>
  );
}

export default App;

一度サーバーを落としてnpm run devで起動したらボタンが表示されました

image.png

2. APIで画像生成を行う

アプリの根幹であるAIによる画像生成を行います。
AI画像生成のAPIはいくつかありますが今回は無料で利用できるStability AIを利用します。

まずは以下の記事を参考にAPIキーを取得するところまで行ってください

APIキーを手に入れたら.envの設定から行います。

$ touch .env
.env
VITE_STABILITY_API_KEY=あなたのAPIキー

VITE_で始めているのはViteで環境変数を読み込むときにプレフィックスがついていないといけないルールがあるからです。

.envを作ったら.gitignoreにも追加しておきましょう

.gitignore
// 末尾に追加
.env

最初はインプットフォームにプロンプトを入れて生成ボタンを押したら画像が表示される仕組みを作ってAPIを利用できるかを確認します。

src/App.tsx
import { useState } from "react";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [prompt, setPrompt] = useState("");

  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  const handleGenerateImage = async () => {
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
        </div>
      )}
    </>
  );
}

export default App;

APIの呼び出しは以下を参考にしました

また画像はBase64で返却されるのでそのまま表示できる形でステートに保存します

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);

A lighthouse on a cliffと入力して生成ボタンを押すと画像が表示されました

image.png

3. 画像を保存する

表示された画像はお気に入り登録をして最後にギャラリーで表示できるようにします。
そこでSupabaseのストレージを今回は利用していきます。

Supabaseのプロジェクトを作成するところまで以下を参考に実施してください

まずはSupabaseのAPIキーを取得します。
「Project Settings」をクリック

image.png

「API」をクリックして「Project URL」と「Project API Keys」の「anon」のキーを使います。

image.png

.envに追加していきます

.env
VITE_STABILITY_API_KEY=あなたのAPIキー
VITE_SUPABASE_URL=あなたのProject URL
VITE_SUPABASE_ANON_KEY=あなたのanonキー

それでは、お気に入りボタンを追加してクリックしたらStorageに保存されるようにしましょう
Supabaseの画面から「Storage」をクリックします

image.png

「New Bucket」を選択

image.png

Nameにgenerate-imageとSaveをクリックします

image.png

このままだとバケットに誰もアクセスできないのでポリシーを作成します
「Policies」をクリックします

image.png

generate-imageの「New policy」をクリック

image.png

「For full customization」をクリック

image.png

Policy name : anon_policy
Allow operation : すべてチェック
Target role : anon

「Review」->「Save policy」の順でクリックして保存

image.png

image.png

これで画像を保存する準備ができました

以下のAPIドキュメントを見ながら保存の実装をしていきます

まずはSupabaseクライアントの初期設定を行います

$ npm i @supabase/supabase-js
$ mkdir src/utils
$ touch src/utils/supabase.ts
supabase.ts
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseKey);

これはsupabaseクライアントを使う際に必ずやる設定になります。
では画像を保存するコードを書いていきましょう

src/App.tsx
import { useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  const handleGenerateImage = async () => {
    console.log(apiKey);
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;

    // Base64文字列からプレフィックスを削除
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");

    // Base64をバイナリデータに変換
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    // 画像をストレージにアップロード
    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
    }
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={handleSaveImage}
          >
            保存
          </button>
        </div>
      )}
    </>
  );
}

export default App;

ポイントはBase64のエンコード(文字列)をバイナリ(写真)に変更して保存することです

    // Base64文字列からプレフィックスを削除
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");

    // Base64をバイナリデータに変換
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

画像生成をすると「保存」ボタンが表示されます。

image.png

保存ボタンをおすとStorageに保存されたことがわかります

image.png

4. 画像を一覧で表示する

次にギャラリーを作るために保存された画像の一覧を取得できるかたしかめます

src/App.tsx
import { useEffect, useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [imageList, setImageList] = useState<string[]>([]);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  useEffect(() => {
    fetchImages();
  }, []);

  async function fetchImages() {
    const { data, error } = await supabase.storage
      .from("generate-image")
      .list();

    if (error) {
      console.error("Error fetching images: ", error);
      return;
    }

    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

          if (signedUrlError) {
            console.error("Error creating signed URL: ", signedUrlError);
            return "";
          }

          return signedUrlData?.signedUrl ?? "";
        })
      );

      setImageList(imageUrls.filter((url) => url !== ""));
    }
  }

  const handleGenerateImage = async () => {
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
      // 画像が保存された後、リストを再取得
      fetchImages();
    }
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      <ul>
        {imageList.map((imageUrl, index) => (
          <li key={index}>
            <img src={imageUrl} alt={`Generated ${index}`} />
          </li>
        ))}
      </ul>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={handleSaveImage}
          >
            保存
          </button>
        </div>
      )}
    </>
  );
}

export default App;

ポイントを紹介します。

    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

supabaseの画像は複数あるためPromise.allで非同期に並列で行っています。
storageからデータを取得すると.emptyFolderPlaceholderという謎のデータが帰ってくるので除外する必要があります。

          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

また、画像は60分だけ有効なURLを発行していまうす

            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

image.png

ここまでで今回のアプリに必要な機能はすべて実装できたのでスタイリングをしていきます。

5. デザインを整える

あとはTailwindCSSでデザインを整えます。
ここは本質ではないのでコードを載せておきますので、各自確認してみてください!

src/App.tsx
import { useEffect, useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [imageList, setImageList] = useState<string[]>([]);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  useEffect(() => {
    fetchImages();
  }, []);

  async function fetchImages() {
    const { data, error } = await supabase.storage
      .from("generate-image")
      .list();

    if (error) {
      console.error("Error fetching images: ", error);
      return;
    }

    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

          if (signedUrlError) {
            console.error("Error creating signed URL: ", signedUrlError);
            return "";
          }

          return signedUrlData?.signedUrl ?? "";
        })
      );

      setImageList(imageUrls.filter((url) => url !== ""));
    }
  }

  const handleGenerateImage = async () => {
    setIsLoading(true);
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      setIsLoading(false);
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
    setIsLoading(false);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
      fetchImages();
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-black via-gray-900 to-gray-800 text-white p-8">
      <h1 className="text-6xl text-center bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600 font-extrabold tracking-wide my-8">
        AI Image Generator
      </h1>
      <div className="flex justify-center mb-8">
        <div className="flex flex-col sm:flex-row gap-2 p-4 bg-gray-800 rounded-lg shadow-lg w-full max-w-xl">
          <input
            type="text"
            placeholder="Describe your imagination..."
            className="flex-grow p-3 bg-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 transition"
            onChange={(e) => setPrompt(e.target.value)}
            value={prompt}
          />
          <button
            disabled={isLoading}
            className="bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-md hover:opacity-80 transition-opacity disabled:opacity-50 flex items-center justify-center"
            onClick={handleGenerateImage}
          >
            {isLoading ? (
              <div className="animate-spin h-5 w-5 border-4 border-white rounded-full border-t-transparent"></div>
            ) : (
              <>Generate</>
            )}
          </button>
        </div>
      </div>

      <div className="mb-12 transition-all duration-500 ease-in-out max-w-xl mx-auto">
        <div className="relative group aspect-square shadow-xl">
          {generatedImage ? (
            <img
              src={generatedImage}
              alt="Generated"
              className="w-full h-full object-cover rounded-lg"
            />
          ) : (
            <div className="w-full h-full bg-gray-700 rounded-lg flex items-center justify-center text-xl">
              Let's generate
            </div>
          )}
          {generatedImage && (
            <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
              <button
                onClick={handleSaveImage}
                className="bg-white bg-opacity-20 backdrop-filter backdrop-blur-sm p-4 rounded-full shadow-md hover:bg-opacity-30 transition-all duration-300"
              ></button>
            </div>
          )}
        </div>
      </div>

      <h2 className="text-3xl font-bold mb-6 flex items-center max-w-xl mx-auto">
        Your Imagination Gallery
      </h2>
      <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 max-w-xl mx-auto">
        {imageList.map((img, index) => (
          <div
            key={index}
            className="relative group aspect-square overflow-hidden rounded-lg shadow-lg transition-all duration-300 hover:scale-105"
          >
            <img
              src={img}
              alt={`Gallery ${index}`}
              className="w-full h-full object-cover"
            />
            <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-0 group-hover:opacity-70 transition-opacity duration-300" />
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

image.png

Peek 2024-09-06 11-28.gif

このあとの開発

余力がある方は以下の開発にも挑戦してみてください!

  1. お気に入りから削除する機能
  2. お気に入りをしたらギャラリーに反映される

おわりに

Supabaseを利用することで簡単に画像ジェネレーターを作ることができました!
ストレージ機能を利用できると個人開発の幅も広がっておすすめです。
ぜひとも今回の内容を活用してみてください!

今回の内容は以下の動画でも学べますのでより詳しく知りたい方はぜひご覧ください

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。

普段はTwitterでエンジニアに関する情報を発信していますのでよければ友達になってください👇

また明日の記事でお会いしましょう!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?

興味のある方は、ぜひホームページからお気軽にご連絡ください!
▼▼▼

97
80
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
97
80