この記事は「mofmof Advent Calendar 2023」14日目の記事です。
はじめに
DALL・E3のAPIでサクッと高クオリティな画像を生成できるらしいということで、簡単な画像生成アプリを作ってみました!
リポジトリはこちら
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コンテナ
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
環境変数の設定
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
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
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コンポーネントは、デフォルトの状態だと外部ホストの画像を利用できません。
今回は外部ストレージに画像を保存・取得するので、ストレージのホストを設定に追加します。
/** @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
'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形式で返却されます
- url (default
- 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
'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
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>
</>
);
}
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
'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
'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
サービスに組み込む場合は、使用用途を限定したり工夫する必要がありそうです。