LoginSignup
2
2

【tRPCの恩恵】T3 Stackを使ったWebアプリ開発

Last updated at Posted at 2023-12-05

はじめに

株式会社マーズフラッグ、フロントエンドエンジニアの安座間です。
今回は、T3 Stackを使った、認証付きの画像生成Webアプリの開発手順と使用感を紹介させていただきます。

画像生成にはLeonardo.AiのAPIを使い、以下の処理を実装していきます。

  1. 画面で入力したプロンプトをサーバーサイドに送信
  2. サーバーサイドでLeonardo.AiのAPIで画像生成
  3. 生成した画像をクライアントに返し画面に表示

レオナルドとは?という方はこちらをご覧ください。

この記事で分かること

  1. T3 Stackの開発体験
  2. tRPCの恩恵
  3. 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

aaasdfadsf.png

Prisma Studioを起動

ログイン後にユーザー情報がDBに保存されるかを確認できるように起動しておきます。

% pnpm db:studio

http://localhost:5555 でこちらの画面が表示されればOK

adfadsfadf.png

ログイン動作確認

画面上の Sign in ボタンをからログインしてみます。
AccountとUserテーブルにレコードが追加されればOK

adfafadf.png
adsfadfa.png

Leonardo.Aiの設定

APIキーを発行

こちらのドキュメントの手順でAPIキーを発行します。なお、APIの利用は有料となりますので、今回は最大380枚ほど生成できるBasicプランを契約しました。

.env にAPIキーと後段で紹介するURLを追記しておきます。

adfadsfadsfadfa.png

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() とすると数値のみとなります。

上記変更をコード上で行うと以下のようにサーバーサイドとクライアントサイドでシームレスに型が共有され即エラーが表示され快適です。

dふぁdふぁdふぁdふぁd.gif

補足
本来、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 が作成されたはずです。

クライアントサイド

プロンプトの入力欄と送信ボタンのシンプルな画面を作っていきます。

あdふぁdふぁふぁd.png

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,
});

adfadfadfadfadf.gif

因みにこちらのuseQueryは データフェッチライブラリのTanStack QueryのuseQueryのラッパーのようです。

動作確認

http://localhost:3000/generator からプロンプトを入力してGenerateしてみると、、
正常に画像を生成することができました。

あdふぁdふぁdふぁふぁfd.gif

プロンプト

# クリスマスをテーマにしたマシュマロの漫画のキャラクター
Cartoon Marshmallow character with Christmas theme

まとめ

いかがでしたか。
T3 Stackを使うと、認証機能付きWebアプリの雛形を簡単に作ることができ、tRPCのコード補完や型チェックにより安全で効率的な開発を体験することができました。

また、Leonardo.AiのAPIは、プロンプトからの画像生成以外にも、i2iやモデルのファインチューニングなども可能ですので、興味が湧いた方は是非使ってみてください。

2
2
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
2
2