はじめに
株式会社マーズフラッグ、フロントエンドエンジニアの安座間です。
今回は、T3 Stackを使った、認証付きの画像生成Webアプリの開発手順と使用感を紹介させていただきます。
画像生成にはLeonardo.AiのAPIを使い、以下の処理を実装していきます。
- 画面で入力したプロンプトをサーバーサイドに送信
- サーバーサイドでLeonardo.AiのAPIで画像生成
- 生成した画像をクライアントに返し画面に表示
レオナルドとは?という方はこちらをご覧ください。
この記事で分かること
- T3 Stackの開発体験
- tRPCの恩恵
- Leonardo.AiのAPIの使い方
T3 Stackとは?
以下3つの思想に基づいたフルスタックフレームワークで、端的にいうと「一貫性のある型でシンプルで再利用可能なWebアプリを作ろうぜ」的な考えでしょうか。
- simplicity
- modularity
- full-stack typesafety
そして、この思想を実現するための技術セットがこちら
tRPCとは?
上記技術の中でも注目したいのがtRPC
スキーマやコード生成を行わずに、完全にタイプセーフな API を簡単に構築できるライブラリのようです。
公式から引用
tRPC allows you to easily build & consume fully typesafe APIs without schemas or code generation.
https://trpc.io/docs/#introduction
サーバーサイドとクライアントサイド間でスキーマなしで型安全なAPIを構築できるので、例えばOpenAPI GeneratorでAPIクライアントを生成して、クライアントにマージして。的な工程が不要なります。
また、サーバー、クライアントサイドともにTypeScriptを使う制約はありますが、 trpc-openapi を使うことでOpenAPI Generatorで多言語のAPIクライアントを生成することもできるようです。(今回は使っていません)
セットアップ
筆者の環境
% node -v
v18.13.0
% pnpm -v
8.6.10
% pnpm create t3-app@latest
.../Library/pnpm/store/v3/tmp/dlx-44332 | +149 +++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
Content-addressable store is at: /Users/mitsufumiazama/Library/pnpm/store/v3
Virtual store is at: ../../../Library/pnpm/store/v3/tmp/dlx-44332/node_modules/.pnpm
.../Library/pnpm/store/v3/tmp/dlx-44332 | Progress: resolved 149, reused 136, downloaded 13, added 149, done
___ ___ ___ __ _____ ___ _____ ____ __ ___ ___
/ __| _ \ __| / \_ _| __| |_ _|__ / / \ | _ \ _ \
| (__| / _| / /\ \| | | _| | | |_ \ / /\ \| _/ _/
\___|_|_\___|_/‾‾\_\_| |___| |_| |___/ /_/‾‾\_\_| |_|
│
◆ What will your project be called?
│
任意のプロジェクト名を入力しEnter
色々と聞かれるので以下の設定で進めます。
Will you be using TypeScript or JavaScript?
TypeScript
Will you be using Tailwind CSS for styling?
Yes
Would you like to use tRPC?
Yes
What authentication provider would you like to use?
NextAuth.js
What database ORM would you like to use?
Prisma
Would you like to use Next.js App Router?
No
Should we initialize a Git repository and stage the changes?
Yes
Should we run 'pnpm install' for you?
Yes
What import alias would you like to use?
~/
認証設定
NextAuth.jsを含めたのでプロバイダーの設定が必要になります。
今回は、一番簡単なDiscordを使用するのでこちらのドキュメントにならい、以下の値を取得し.envに追記します。
DISCORD_CLIENT_ID="取得した値"
DISCORD_CLIENT_SECRET="取得した値"
ローカルサーバーを起動
% pnpm dev
http://localhost:3000 でこちらの画面が表示されればOK
Prisma Studioを起動
ログイン後にユーザー情報がDBに保存されるかを確認できるように起動しておきます。
% pnpm db:studio
http://localhost:5555 でこちらの画面が表示されればOK
ログイン動作確認
画面上の Sign in ボタンをからログインしてみます。
AccountとUserテーブルにレコードが追加されればOK
Leonardo.Aiの設定
APIキーを発行
こちらのドキュメントの手順でAPIキーを発行します。なお、APIの利用は有料となりますので、今回は最大380枚ほど生成できるBasicプランを契約しました。
.env にAPIキーと後段で紹介するURLを追記しておきます。
LEONARDOAI_BASE_URL="https://cloud.leonardo.ai/api/rest/v1/generations"
LEONARDOAI_API_KEY="{APIキー}"
画像生成の流れ
APIを使った画像生成の流れは、プロンプトからID(generationId)を取得 → 取得したIDを使い画像を生成 となり、以下の2つのエンドポイントを使用します。
Create a Generation of Images
Request
const options = {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
authorization: 'Bearer {APIキー}'
},
body: JSON.stringify({
height: 512,
prompt: '{プロンプト}',
width: 512
})
};
fetch('https://cloud.leonardo.ai/api/rest/v1/generations', options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));
Response
{
"sdGenerationJob": {
"generationId": "bc01981-3312-4229-a2de-fa7d52988290",
"apiCreditCost": 11
}
}
オプションで画像サイズやネガティブプロンプト、コントラストや解像度など色々と指定できます。
Get a Single Generation
Request
const options = {
method: 'GET',
headers: {
accept: 'application/json',
authorization: 'Bearer {APIキー}'
}
};
fetch('https://cloud.leonardo.ai/api/rest/v1/generations/bc01981-3312-4229-a2de-fa7d52988290', options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));
Response
{
"generations_by_pk": {
"generated_images": [
{
"url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_0.jpg",
"nsfw": false,
"id": "170bcef8-6b69-47eb-a7d7-f63b6c242323",
"likeCount": 0,
"generated_image_variation_generics": []
},
{
"url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_1.jpg",
"nsfw": false,
"id": "ab50bb5c-0b46-48ef-b7ba-23ea7b518bbb",
"likeCount": 0,
"generated_image_variation_generics": []
},
{
"url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_2.jpg",
"nsfw": false,
"id": "2bf2ef2a-ae7e-40cf-a6c6-3914a730f528",
"likeCount": 0,
"generated_image_variation_generics": []
},
{
"url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_3.jpg",
"nsfw": false,
"id": "c5faf06f-dc02-4408-ad33-653309b387c8",
"likeCount": 0,
"generated_image_variation_generics": []
}
],
"modelId": "6bef9f1b-29cb-40c7-b9df-32b51c1f67d3",
"prompt": "An oil painting of a cat",
"negativePrompt": "",
"imageHeight": 512,
"imageWidth": 512,
"inferenceSteps": 30,
"seed": 465788672,
"public": false,
"scheduler": "EULER_DISCRETE",
"sdVersion": "v2",
"status": "COMPLETE",
"presetStyle": null,
"initStrength": null,
"guidanceScale": 7,
"id": "fbc01981-3312-4229-a2de-fa7d52988290",
"createdAt": "2023-12-03T13:41:38.253",
"promptMagic": false,
"promptMagicVersion": null,
"promptMagicStrength": null,
"photoReal": false,
"photoRealStrength": null,
"fantasyAvatar": null,
"generation_elements": []
}
}
サーバーサイド
クライアントから受け取ったプロンプトをLeonardo.Aiに渡して画像を生成し、クライアントに返すシンプルな処理を実装します。
エンドポイントを追加
src/server/api/routers/image.ts を作成
import { z } from "zod";
import {
createTRPCRouter,
publicProcedure,
} from "~/server/api/trpc";
import type { LeonardoGenerationsImages, LeonardoGenerations } from "~/types";
export const imageRouter = createTRPCRouter({
generate: publicProcedure
.input(z.object({ prompt: z.string() }))
.query(async ({ input }) => {
const generationsResponse = await fetch(`${process.env.LEONARDOAI_BASE_URL}`, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
authorization: `Bearer ${process.env.LEONARDOAI_API_KEY}`
},
body: JSON.stringify({
height: 512,
prompt: input.prompt,
width: 512
})
});
const generations: LeonardoGenerations = await generationsResponse.json();
await new Promise((resolve) => setTimeout(resolve, 20000));
const url = `${process.env.LEONARDOAI_BASE_URL}/${generations.sdGenerationJob.generationId}`;
const generationsImagesResponse = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${process.env.LEONARDOAI_API_KEY}`
}
});
const generationsImages: LeonardoGenerationsImages = await generationsImagesResponse.json();
return {
images: generationsImages.generations_by_pk?.generated_images,
};
}),
});
今回の肝となるtRPCがこちら
generate: publicProcedure
.input(z.object({ prompt: z.string() }))
.query(async ({ input }) => {
//省略...
}),
バリデーションライブリである zod を使いクライアントサイドからのリクエストの引数をチェック。z.string() で promptの値を文字列のみ許可していますが、z.number() とすると数値のみとなります。
上記変更をコード上で行うと以下のようにサーバーサイドとクライアントサイドでシームレスに型が共有され即エラーが表示され快適です。
補足
本来、Leonardo.Aiの画像生成が完了するまで数秒時間がかかるので、画像生成の完了通知をWebhookで受信する必要があります。
.
.
が、今回は本筋とずれるという正当な理由でsetTimeoutで割愛してます。
詳細はこちらに記載があります。
src/server/api/root.ts にimageRouterを追加
createTRPCRouterに先程作成したimageRouterを追加します。
import { postRouter } from "~/server/api/routers/post";
import { imageRouter } from "~/server/api/routers/image"; // 追加
import { createTRPCRouter } from "~/server/api/trpc";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
post: postRouter,
image: imageRouter, //追加
});
// export type definition of API
export type AppRouter = typeof appRouter;
これでAPIエンドポイント http://localhost:3000/api/trpc/image.generate が作成されたはずです。
クライアントサイド
プロンプトの入力欄と送信ボタンのシンプルな画面を作っていきます。
src/pages/generator/index.tsx を追加
import { useState } from "react";
import { api } from "~/utils/api";
import { type GeneratedImage } from "~/types";
import Image from "next/image";
export default function Home() {
const [prompt, setPrompt] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
}
const { isLoading, fetchStatus, data, refetch } = api.image.generate.useQuery({ prompt }, {
enabled: false,
});
const generateImage = async () => {
await refetch();
}
return (
<main className="py-32 text-center">
<div className="mb-10">
<div className="mb-5">
<textarea
defaultValue={prompt}
onChange={(e) => handleChange(e)}
className="textarea textarea-bordered textarea-md w-5/12 text-white"
>
</textarea>
</div>
<button
className="btn btn-primary btn-wide text-white"
onClick={ () => void generateImage() }
>
Generate
</button>
</div>
{isLoading && fetchStatus === "fetching" ? (
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 animate-spin m-auto">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</div>
): null}
{data ? (
<ul className="lg:flex">
{data?.images.map((image: GeneratedImage, index: number) => (
<li key={index} className="p-7">
<Image
src={image.url}
alt={`image_${index}`}
width="512"
height="512"
className="m-auto"
/>
</li>
))}
</ul>
): null}
</main>
);
}
以下の箇所でバックエンドにプロンプトを渡していますが、コーディングで補完が効き、バックエンドで定義した型(string)が共有されているのを確認することができます。
const { isLoading, fetchStatus, data, refetch } = api.image.generate.useQuery({ prompt }, {
enabled: false,
});
因みにこちらのuseQueryは データフェッチライブラリのTanStack QueryのuseQueryのラッパーのようです。
動作確認
http://localhost:3000/generator からプロンプトを入力してGenerateしてみると、、
正常に画像を生成することができました。
プロンプト
# クリスマスをテーマにしたマシュマロの漫画のキャラクター
Cartoon Marshmallow character with Christmas theme
まとめ
いかがでしたか。
T3 Stackを使うと、認証機能付きWebアプリの雛形を簡単に作ることができ、tRPCのコード補完や型チェックにより安全で効率的な開発を体験することができました。
また、Leonardo.AiのAPIは、プロンプトからの画像生成以外にも、i2iやモデルのファインチューニングなども可能ですので、興味が湧いた方は是非使ってみてください。