DALL・E3 APIとNext.jsで画像生成アプリ的なやつを作ってみた

:::note
この記事は「[mofmof Advent Calendar 2023](https://qiita.com/advent-calendar/2023/mofmof)」14日目の記事です。
:::

# はじめに
DALL・E3のAPIでサクッと高クオリティな画像を生成できるらしいということで、簡単な画像生成アプリを作ってみました！

こんなの
![スクリーンショット 2023-12-13 21.58.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3081679/03c761eb-a303-7dd2-e48f-65165bacaf54.png)

リポジトリはこちら  
https://github.com/HiroKb/dalle3-sample-app
細かい部分は省略しているので、気になる部分があればご覧ください。

# 使ったもの
- Next.js
    - App Router
    - Server Action
- shadcn/ui
- openai-node
- aws-sdk
- docker
- minio
- prisma


Server Actionやらshadcn/uiやらは素振りも兼ねて使ってみました。
# Docker
- Next用のappコンテナ
- PostgreSQLのdbコンテナ
- 画像保存用のminioコンテナ
```compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: ./docker/app/Dockerfile
    ports:
      - "3000:3000"
    volumes:
      - ./app:/app
      - node_modules:/app/node_modules
    stdin_open: true
    tty: true
    depends_on:
      - db
      - minio
    command: bash -c "npx prisma migrate dev && npm run dev"

  db:
    image: postgres:15.4
    ports:
      - "5432:5432"
    volumes:
        - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
      POSTGRES_DB: app-dev

  mc:
    image: minio/mc:latest
    depends_on:
      - minio
    entrypoint: >
      /bin/sh -c "
      mc alias set minio http://minio:9000 user password;
      mc mb minio/bucket;
      mc anonymous set public minio/bucket;
      "

  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9090:9090"
    volumes:
      - minio-data:/data
    command: "server /data --console-address :9090"
    environment:
      MINIO_ROOT_USER: user
      MINIO_ROOT_PASSWORD: password

volumes:
  db-data:
  minio-data:
  node_modules:
```


# とりあえず色々導入する
Next、shadcn/uiの導入とライブラリ類をインストールします。
```
docker compose up
docker compose exec app bash
```

```
npx create-next-app@latest dalle3-app --typescript --tailwind --eslint
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*
```
```
npx shadcn-ui@latest init
✔ Would you like to use TypeScript (recommended)? … yes
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? … src/app/globals.css
✔ Would you like to use CSS variables for colors? … yes
✔ Where is your tailwind.config.js located? … tailwind.config.js
✔ Configure the import alias for components: … @/components
✔ Configure the import alias for utils: … @/lib/utils
✔ Are you using React Server Components? … yes
✔ Write configuration to components.json. Proceed? … yes
```
```
npm i next-themes open-ai zod @aws-sdk/client-s3 @aws-sdk/lib-storage server-only
```
```
npm i -D prisma
```

上記に加え、shadcd/ui公式を参考にフォントとダークモードの設定を行いました。
https://ui.shadcn.com/docs/installation/next  
https://ui.shadcn.com/docs/dark-mode/next  

\
使用するshadcn/uiのコンポーネントを追加しておきます。
```
npx shadcn-ui@latest add aspect-ratio button dialog input label popover
```

# 環境変数の設定
```app/.env
DATABASE_URL="postgresql://postgres:postgres@db:5432/app-dev?schema=public"

OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

STORAGE_REGION='ap-northeast-1'
STORAGE_ACCESS_KEY_ID=user
STORAGE_SECRET_ACCESS_KEY=password
STORAGE_ENDPOINT=http://minio:9000
STORAGE_BUCKET_NAME=bucket

STORAGE_PUBLIC_PROTOCOL=http
STORAGE_PUBLIC_HOSTNAME=minio
STORAGE_PUBLIC_PORT=9000
```
- DBの接続設定
- OpenAIのAPIキー
- 画像用ストレージの接続情報
等を設定してます。

# Prisma周り
```
npx prisma init
```

```app/prisma/schema.prisma
model Image {
  id            String   @id @default(uuid())
  prompt        String
  revisedPrompt String   @map("revised_prompt")
  key           String
  createdAt     DateTime @default(now()) @map("created_at")

  @@map("images")
}
```
- prompt
    - OpenAIに投げた生成指示文。
- revisedPrompt
    - OpenAI側でpromptをdalle用に最適化してくれたやつ。このrevisedPormptを使って画像生成している。
    - APIを叩いた際に、レスポンスで返ってくるので保存しておく。
- key
    - ストレージに保存する際のキー = ファイル名
```
npx prisma migrate dev --name init
```



```app/src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const prismaClientSingleton = () => {
  return new PrismaClient()
}

declare global {
  var prisma: undefined | ReturnType<typeof prismaClientSingleton>
}

const prisma = globalThis.prisma ?? prismaClientSingleton()

export default prisma

if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma
```
開発環境でPrismaのインスタンスが複数生成されないように対策します。

https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices

# Next/Image用の設定
NextのImageコンポーネントは、デフォルトの状態だと外部ホストの画像を利用できません。
今回は外部ストレージに画像を保存・取得するので、ストレージのホストを設定に追加します。
```app/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: process.env.STORAGE_PUBLIC_PROTOCOL,
        hostname: process.env.STORAGE_PUBLIC_HOSTNAME,
        port: process.env.STORAGE_PUBLIC_PORT,
      }
    ]
  }
}

module.exports = nextConfig

```
# 画像を生成してストレージに保存する
## ServerAction
```app/src/app/(image)/_actions/generate-image.ts
'use server'

import 'server-only'
import OpenAI from "openai";
import {Upload} from "@aws-sdk/lib-storage";
import {StreamingBlobPayloadInputTypes} from "@smithy/types";
import {z} from "zod";
import {redirect} from "next/navigation";
import {revalidatePath} from "next/cache";

import prisma from "@/lib/prisma";
import {getS3Client} from "@/lib/s3-client";

const generateImageSchema = z.object({
  prompt: z.string().min(1).max(1000),
})

export const generateImage = async (_prevState: any, formData: FormData) => {
  const parseInputResult = generateImageSchema.safeParse(Object.fromEntries(formData.entries()));

  if (!parseInputResult.success) {
    return {
      messages: parseInputResult.error.flatten().fieldErrors.prompt
    }
  }

  try {
    const dalle3Image = await generateImageByDalle3(parseInputResult.data.prompt)

    const imageStream = await fetchImage(dalle3Image.url)

    const uploadResult = await uploadImage(imageStream)

    await prisma.image.create({
      data: {
        prompt: parseInputResult.data.prompt,
        revisedPrompt: dalle3Image.revised_prompt,
        key: uploadResult.Key
      }
    })
  } catch (e) {
    console.log(e)
    return {
      messages: ['Internal server error']
    }
  }

  revalidatePath('/')
  redirect('/')
}

const openAIImageSchema = z.object({
  revised_prompt: z.string(),
  url: z.string(),
})

const uploadResultSchema = z.object({
  Key: z.string(),
})

const generateImageByDalle3 = async (prompt: string) => {
  const openai = new OpenAI()

  const generatedResponse = await openai.images.generate({
    model: 'dall-e-3',
    prompt: prompt,
    n: 1,
    size: '1024x1024',
    quality: 'standard',
    response_format: 'url',
    style: 'vivid',
  })

  return openAIImageSchema.parse(generatedResponse.data[0])
}

const fetchImage = async (url: string) => {
  const imageFetchResponse = await fetch(url, {
    cache: 'no-cache',
  })

  if (!imageFetchResponse.body) {
    throw new Error('Image file not found')
  }

  return imageFetchResponse.body
}

const uploadImage = async (
  file: StreamingBlobPayloadInputTypes,
  fileName: string = `${crypto.randomUUID()}${Date.now()}.png`,
  contentType: 'image/png' | 'image/jpeg' = 'image/png'
) => {

  const upload = new Upload({
    client: getS3Client(),
    params: {
      Bucket: process.env.STORAGE_BUCKET_NAME,
      Key: fileName,
      Body: file,
      ContentType: contentType,
    },
  });

  return uploadResultSchema.parse(await upload.done())
}
```

### 1. 画像を生成する
```typescript
const generateImageByDalle3 = async (prompt: string) => {
  const openai = new OpenAI()

  const generatedResponse = await openai.images.generate({
    model: 'dall-e-3',
    prompt: prompt,
    n: 1,
    size: '1024x1024',
    quality: 'standard',
    response_format: 'url',
    style: 'vivid',
  })

  return openAIImageSchema.parse(generatedResponse.data[0])
}
```
**OpenAI.image.generate**メソッドで画像を生成しています。
- model: 使用するモデル
    - dall-e-2 (default
    - dall-e-3
- prompt: 生成指示文
- n: 生成枚数
    - 1~10 (dalle2
    - 1のみ (dalle3
- size: 画像の縦横サイズ
    - 256x256 (dalle2
    - 512x512 (dalle2
    - 1024x1024 (dalle2,dalle3
    - 1792x1024 (dalle3
    - 1024x1792 (dalle3
- quality: 画質
    - standard (default
    - hd
- response_format: 生成された画像の返却形式
    - url (default
        - 生成された画像がOpenAIの用意しているストレージに保存され、URLが返却されます。
        - 画像は1時間?ほどで削除されアクセスできなくなります
    - b64_json
        - base64形式で返却されます
- style = 生成される画像のテイスト
    - vivid (default
    - natural

https://cookbook.openai.com/articles/what_is_new_with_dalle_3

### 2.画像を取得する
生成された画像を取得しておきます。
```typescript
const fetchImage = async (url: string) => {
  const imageFetchResponse = await fetch(url, {
    cache: 'no-cache',
  })

  if (!imageFetchResponse.body) {
    throw new Error('Image file not found')
  }

  return imageFetchResponse.body
}
```

### 3.画像をストレージにアップロードする
2で取得した画像をストレージ = minioにアップロードします。
Stream形式で画像を取得しているので、@aws-sdk/lib-storage.Uploadを使ってアップロードしています。
```typescript
const uploadImage = async (
  file: StreamingBlobPayloadInputTypes,
  fileName: string = `${crypto.randomUUID()}${Date.now()}.png`,
  contentType: 'image/png' | 'image/jpeg' = 'image/png'
) => {

  const upload = new Upload({
    client: getS3Client(),
    params: {
      Bucket: process.env.STORAGE_BUCKET_NAME,
      Key: fileName,
      Body: file,
      ContentType: contentType,
    },
  });

  return uploadResultSchema.parse(await upload.done())
}
```

## Component
```app/src/app/(image)/_components/GenerateImageModal.tsx
'use client'

import React from "react";
import {useFormState} from "react-dom";

import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Dialog, DialogContent, DialogHeader, DialogTrigger} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import SubmitButton from "@/components/form/SubmitButton";

import {generateImage} from "@/app/(image)/_actions/generate-image";

function GenerateImageModal() {
  const [state, formAction] = useFormState(generateImage, undefined)

  return (

    <Dialog>
      <DialogTrigger asChild>
        <Button>Generate Image</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          Generate Image
        </DialogHeader>
        <form action={formAction}>
          <Label>Prompt</Label>
          <Input
            name='prompt'
            className='mt-2'
          />
          {state?.messages && state.messages.map((message) => (
            <p key={message} className='text-red-500 text-sm mt-2'>{message}</p>
          ))
          }
          <div className='flex justify-end mt-4'>
            <SubmitButton>
              Generate
            </SubmitButton>
          </div>
        </form>
      </DialogContent>
    </Dialog>
  );
}

export default React.memo(GenerateImageModal);

```

# 画像を表示する

## Component
```app/src/app/(image)/page.tsx
import React from "react";

import prisma from "@/lib/prisma";

import GenerateImageModal from "@/app/(image)/_components/GenerateImageModal";
import ImageCard from "@/app/(image)/_components/ImageCard";


export default async function Home() {
  const images = await prisma.image.findMany({
    orderBy: {
      createdAt: "desc"
    }
  });

  return (
    <>
      <header
        className="py-4 sticky top-0 bg-gray-100 dark:bg-black opacity-90"
        style={{zIndex: 1000}}
      >
        <div className='container flex justify-end'>
          <GenerateImageModal/>
        </div>
      </header>
      <main className="container grid grid-cols-3 gap-8 mt-6">
        {images.map((image) => (
          <ImageCard
            key={image.id}
            image={image}
          />
        ))}
      </main>
    </>
  );
}
```
```src/app/(image)/_components/ImageCard.tsx
import React from "react";

import Image from "next/image";
import {Image as ImageType} from "@prisma/client";

import {AspectRatio} from "@/components/ui/aspect-ratio";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";

import {getPublicImageUrl} from "@/lib/server-utils";
import DeleteImageButton from "@/app/(image)/_components/DeleteImageButton";

type Props = {
  image: ImageType
} & React.ComponentProps<typeof AspectRatio>

function ImageCard({image, ...rest}: Props) {

  return (
    <Popover>
      <PopoverTrigger asChild>
        <AspectRatio
          className="relative rounded-md overflow-hidden shadow-lg shadow-gray-500 dark:shadow-gray-800 cursor-pointer"
          {...rest}
        >
          <Image
            src={getPublicImageUrl(image.key)}
            alt={image.prompt}
            fill
          />
        </AspectRatio>
      </PopoverTrigger>
      <PopoverContent className="p-4 border-2 w-96">
        <h4 className='text-xl font-semibold'>Prompt</h4>
        <p>{image.prompt}</p>
        <h4 className='text-xl font-semibold mt-6'>Revised Prompt</h4>
        <p>{image.revisedPrompt}</p>

        <DeleteImageButton
          image={image}
        />
      </PopoverContent>
    </Popover>
  );
}

export default React.memo(ImageCard);

```

# 画像を削除する
## ServerAction
```app/src/app/(image)/_actions/delete-image.ts
'use server'
import 'server-only'

import {DeleteObjectCommand} from "@aws-sdk/client-s3";
import {z} from "zod";
import {redirect} from "next/navigation";
import {revalidatePath} from "next/cache";

import prisma from "@/lib/prisma";
import {getS3Client} from "@/lib/s3-client";

export const deleteImage = async (_prevState: any, formData: FormData) => {
  try {
    const id = z.string().min(1).parse(formData.get('id'))

    const image = await prisma.image.findUniqueOrThrow({
      where: {
        id: id
      }
    })

    await prisma.$transaction(async (prismaClient) => {
      await prismaClient.image.delete({
        where: {
          id: id
        }
      })

      await getS3Client().send(new DeleteObjectCommand({
        Bucket: process.env.STORAGE_BUCKET_NAME,
        Key: image.key
      }))
    })

  } catch (e) {
    console.log(e)
    return {message: 'Internal server error'}
  }

  revalidatePath('/')
  redirect('/')
}
```

## Comonent
```app/src/app/(image)/_components/DeleteImageButton.tsx
'use client'
import React from "react";
import {useFormState} from "react-dom";
import {Image} from "@prisma/client";

import SubmitButton from "@/components/form/SubmitButton";

import {deleteImage} from "@/app/(image)/_actions/delete-image";

type Props = {
  image: Image
} & React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>

function DeleteImageButton({image, ...rest}: Props) {
  const [state, formAction] = useFormState(deleteImage, undefined)

  return (
    <form
      action={formAction}
      className='flex justify-end'
      {...rest}
    >
      <input type='hidden' name='id' value={image.id}/>
      {state?.message && (
        <p className='text-red-500 text-sm mt-2'>{state.message}</p>
      )}
      <SubmitButton
        className='ml-4'
      >
        Delete
      </SubmitButton>
    </form>
  );
}

export default React.memo(DeleteImageButton);

```

# 終わりに
画像生成部分はサクッと作ることができました！
気軽に使っていきたいなーと思うのですが、問題となるのはコストの部分ですね・・・
現時点だとdall-e-3モデルを使った画像生成は、１枚あたり最低$0.04かかってしまいます。
https://openai.com/pricing


サービスに組み込む場合は、使用用途を限定したり工夫する必要がありそうです。