LoginSignup
8
1

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

Last updated at Posted at 2023-12-13

この記事は「mofmof Advent Calendar 2023」14日目の記事です。

はじめに

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

こんなの
スクリーンショット 2023-12-13 21.58.10.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のインスタンスが複数生成されないように対策します。

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. 画像を生成する

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

2.画像を取得する

生成された画像を取得しておきます。

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を使ってアップロードしています。

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モデルを使った画像生成は、1枚あたり最低$0.04かかってしまいます。
https://openai.com/pricing

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

8
1
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
8
1