はじめに
こんにちは、Watanabe Jin(@Sicut_study)です。
アプリを作る上で事前に手札が多いほうが、実現できるアイデアの幅も広くなります。
今回は「AIによる画像生成」「ストレージへの保存」をメインとした画像ジェネレーターをReactで実装するアプリケーションを作成していきましょう。
今回は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環境が無事できています。
次にTailwindCSSを導入していきます。
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
プロジェクトをVSCodeで開いてtailwind.config.js
をいかに変えます
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
src/index.css
を変更します
@tailwind base;
@tailwind components;
@tailwind utilities;
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
で起動したらボタンが表示されました
2. APIで画像生成を行う
アプリの根幹であるAIによる画像生成を行います。
AI画像生成のAPIはいくつかありますが今回は無料で利用できるStability AIを利用します。
まずは以下の記事を参考にAPIキーを取得するところまで行ってください
APIキーを手に入れたら.env
の設定から行います。
$ touch .env
VITE_STABILITY_API_KEY=あなたのAPIキー
VITE_
で始めているのはViteで環境変数を読み込むときにプレフィックスがついていないといけないルールがあるからです。
.env
を作ったら.gitignore
にも追加しておきましょう
// 末尾に追加
.env
最初はインプットフォームにプロンプトを入れて生成ボタンを押したら画像が表示される仕組みを作ってAPIを利用できるかを確認します。
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
と入力して生成ボタンを押すと画像が表示されました
3. 画像を保存する
表示された画像はお気に入り登録をして最後にギャラリーで表示できるようにします。
そこでSupabase
のストレージを今回は利用していきます。
Supabaseのプロジェクトを作成するところまで以下を参考に実施してください
まずはSupabaseのAPIキーを取得します。
「Project Settings」をクリック
「API」をクリックして「Project URL」と「Project API Keys」の「anon」のキーを使います。
.env
に追加していきます
VITE_STABILITY_API_KEY=あなたのAPIキー
VITE_SUPABASE_URL=あなたのProject URL
VITE_SUPABASE_ANON_KEY=あなたのanonキー
それでは、お気に入りボタンを追加してクリックしたらStorageに保存されるようにしましょう
Supabaseの画面から「Storage」をクリックします
「New Bucket」を選択
Nameにgenerate-image
とSaveをクリックします
このままだとバケットに誰もアクセスできないのでポリシーを作成します
「Policies」をクリックします
generate-imageの「New policy」をクリック
「For full customization」をクリック
Policy name : anon_policy
Allow operation : すべてチェック
Target role : anon
「Review」->「Save policy」の順でクリックして保存
これで画像を保存する準備ができました
以下のAPIドキュメントを見ながら保存の実装をしていきます
まずはSupabaseクライアントの初期設定を行います
$ npm i @supabase/supabase-js
$ mkdir src/utils
$ touch src/utils/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クライアントを使う際に必ずやる設定になります。
では画像を保存するコードを書いていきましょう
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)
);
画像生成をすると「保存」ボタンが表示されます。
保存ボタンをおすとStorageに保存されたことがわかります
4. 画像を一覧で表示する
次にギャラリーを作るために保存された画像の一覧を取得できるかたしかめます
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);
ここまでで今回のアプリに必要な機能はすべて実装できたのでスタイリングをしていきます。
5. デザインを整える
あとはTailwindCSSでデザインを整えます。
ここは本質ではないのでコードを載せておきますので、各自確認してみてください!
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;
このあとの開発
余力がある方は以下の開発にも挑戦してみてください!
- お気に入りから削除する機能
- お気に入りをしたらギャラリーに反映される
おわりに
Supabaseを利用することで簡単に画像ジェネレーターを作ることができました!
ストレージ機能を利用できると個人開発の幅も広がっておすすめです。
ぜひとも今回の内容を活用してみてください!
今回の内容は以下の動画でも学べますのでより詳しく知りたい方はぜひご覧ください
ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。
普段はTwitterでエンジニアに関する情報を発信していますのでよければ友達になってください👇
また明日の記事でお会いしましょう!
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にご連絡ください!
▼▼▼