はじめに
本記事は#AWSAmplifyJP Advent Calendar 2023の13日目の記事です。
クソアプリ Advent Calendar 2023に参加するために、ChatGPTでDalle(画像生成)用のプロンプトを作りました。
尚、アプリの機能は下記記事で紹介しています。
Demo
使った技術
この記事では使った技術を紹介したいと思います。
Amplify
Amplifyの機能を多く使ったので、コード等とともに紹介します。
Figma to Code
まず、FigmaでLPや、Webアプリのコンポーネントを作り、Figma to Code機能でReactのコードを得ました。アプリの種類にもよりますが、自分でゼロからコード書く場合の数分の一から半分のコーディングで済みます。AIにコードを書いてもらうvercelのv0等が最近登場しましたが、個人的にはプロンプトで指示するよりGUIで書いたものがコードになる方が好きです。
自動生成されたReactコードの一例
/***************************************************************************
* The contents of this file were generated with Amplify Studio. *
* Please refrain from making any modifications to this file. *
* Any changes to this file will be overwritten when running amplify pull. *
**************************************************************************/
/* eslint-disable */
import * as React from "react";
import { getOverrideProps } from "./utils";
import { Button, Flex, Image, Text } from "@aws-amplify/ui-react";
export default function HeroView(props) {
const { overrides, ...rest } = props;
return (
<Flex
gap="0"
direction="row"
width="1490px"
height="unset"
justifyContent="center"
alignItems="center"
position="relative"
padding="0px 0px 0px 0px"
{...getOverrideProps(overrides, "HeroView")}
{...rest}
>
<Flex
gap="10px"
direction="column"
width="720px"
height="392px"
justifyContent="space-between"
alignItems="center"
shrink="0"
position="relative"
padding="0px 0px 0px 0px"
{...getOverrideProps(overrides, "Frame 3")}
>
<Flex
gap="24px"
direction="column"
width="316px"
height="unset"
justifyContent="center"
alignItems="center"
grow="1"
shrink="1"
basis="0"
position="relative"
padding="0px 0px 0px 0px"
{...getOverrideProps(overrides, "HeroMessage")}
>
<Flex
gap="16px"
direction="column"
width="unset"
height="unset"
justifyContent="center"
alignItems="center"
shrink="0"
alignSelf="stretch"
position="relative"
padding="0px 0px 0px 0px"
{...getOverrideProps(overrides, "Message")}
>
<Text
fontFamily="Inter"
fontSize="16px"
fontWeight="700"
color="rgba(64,170,191,1)"
lineHeight="24px"
textAlign="center"
display="block"
direction="column"
justifyContent="unset"
width="unset"
height="unset"
gap="unset"
alignItems="unset"
shrink="0"
alignSelf="stretch"
position="relative"
padding="0px 0px 0px 0px"
whiteSpace="pre-wrap"
children="Easy Image"
{...getOverrideProps(overrides, "Eyebrow")}
></Text>
<Text
fontFamily="Inter"
fontSize="24px"
fontWeight="600"
color="rgba(13,26,38,1)"
lineHeight="30px"
textAlign="center"
display="block"
direction="column"
justifyContent="unset"
width="unset"
height="unset"
gap="unset"
alignItems="unset"
shrink="0"
alignSelf="stretch"
position="relative"
padding="0px 0px 0px 0px"
whiteSpace="pre-wrap"
children="画像を簡単に生成"
{...getOverrideProps(overrides, "Heading")}
></Text>
<Text
fontFamily="Inter"
fontSize="16px"
fontWeight="400"
color="rgba(48,64,80,1)"
lineHeight="24px"
textAlign="center"
display="block"
direction="column"
justifyContent="unset"
letterSpacing="0.01px"
width="unset"
height="unset"
gap="unset"
alignItems="unset"
shrink="0"
alignSelf="stretch"
position="relative"
padding="0px 0px 0px 0px"
whiteSpace="pre-wrap"
children="生成したい画像について簡単に伝えるだけで、自動で画像生成用のプロンプトを作り、実行できます。"
{...getOverrideProps(overrides, "Body")}
></Text>
</Flex>
<Button
width="unset"
height="unset"
shrink="0"
size="large"
isDisabled={false}
variation="primary"
children="試してみる"
{...getOverrideProps(overrides, "Button")}
></Button>
</Flex>
</Flex>
<Image
width="720px"
height="392px"
display="block"
gap="unset"
alignItems="unset"
justifyContent="unset"
shrink="0"
position="relative"
padding="0px 0px 0px 0px"
objectFit="cover"
{...getOverrideProps(overrides, "1700652527621-cat 1")}
></Image>
</Flex>
);
}
Amplify Form Builder, react-modal
入力フォームはForm Builderで作りました。react-modalで、一つのフォームが入力終わって送信されたら次のモーダルが開くようにしました。
コード
"use client";
import Modal from "react-modal";
import { CreatedImage } from "@/models";
import {
createFromDalleAndSaveToS3,
getSignedS3UrlFromKey,
} from "@/utils/createSaveImage";
import { Button, Flex, Loader } from "@aws-amplify/ui-react";
import { DataStore } from "aws-amplify/datastore";
import React, {
ChangeEvent,
useState,
} from "react";
import PromptCreateForm from "@/ui-components/PromptCreateForm";
import PromptEditForm from "@/ui-components/PromptEditForm";
import createPrompt from "@/utils/chat";
import ImageCreateSuccessView from "@/ui-components/ImageCreateSuccessView";
export default function CreateSaveImageModal({
openApiKey,
}: {
openApiKey: string;
}) {
const [modalToOpen, setModalToOpen] = useState("");
const [generationTarget, setGenerationTarget] = useState("");
const [adjective, setAdjective] = useState("");
const [languageOfPrompt, setLanguageOfPrompt] = useState("");
const [numberOfWordsPrompt, setNumberOfWordsPrompt] = useState("");
const [prompt, setPrompt] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [loadingDalle, setLoadingDalle] = useState(false);
const createImage = async (prompt: string) => {
setLoadingDalle(true);
const key = await createFromDalleAndSaveToS3(openApiKey, prompt);
const url = await getSignedS3UrlFromKey(key);
setImageUrl(url);
DataStore.save(new CreatedImage({ title: prompt, s3Url: key }));
setLoadingDalle(false);
};
const onSubmitPromptCreateForm = async () => {
setModalToOpen("CreatingPrompt");
const resp = await createPrompt({
openApiKey: openApiKey,
generationTarget: generationTarget,
adjective: adjective,
languageOfPrompt: languageOfPrompt,
numberOfWordsPrompt: numberOfWordsPrompt,
});
setPrompt(resp);
setModalToOpen("PromptEditForm");
};
const onSubmitPromptEditForm = async () => {
setModalToOpen("CreatingImage");
await createImage(prompt);
setModalToOpen("ImageCreateSuccessView");
};
return (
<>
{loadingDalle ? (
"Creating Image..."
) : (
<Button
variation="primary"
onClick={() => setModalToOpen("PromptCreateForm")}
>
プロンプトを作って画像を生成する
</Button>
)}
<Modal isOpen={modalToOpen == "PromptCreateForm"}>
<PromptCreateForm
onSubmit={onSubmitPromptCreateForm}
overrides={{
generationTarget: {
value: generationTarget,
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setGenerationTarget(event.target.value),
},
adjective: {
value: adjective,
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setAdjective(event.target.value),
},
languageOfPrompt: {
value: languageOfPrompt,
onChange: (event: any) => {
setLanguageOfPrompt(event.target.value);
// console.log("languageOfPrompt:", languageOfPrompt);
},
},
numberOfWordsPrompt: {
value: numberOfWordsPrompt,
onChange: (event: any) => {
setNumberOfWordsPrompt(event.target.value);
console.log("numberOfWordsPrompt:", numberOfWordsPrompt);
},
},
}}
/>
</Modal>
<Modal isOpen={modalToOpen == "CreatingPrompt"}>
<Flex
height="100%"
direction="column"
alignItems="center"
justifyContent={"center"}
>
{"プロンプトを生成しています。。。"}
<Loader variation="linear" />
</Flex>
</Modal>
<Modal isOpen={modalToOpen == "PromptEditForm"}>
<PromptEditForm
onSubmit={onSubmitPromptEditForm}
overrides={{
Field0: {
defaultValue: prompt,
value: prompt,
onChange: (event: any) => {
let { value } = event.target;
setPrompt(value);
},
},
}}
/>
</Modal>
<Modal isOpen={modalToOpen == "CreatingImage"}>
<Flex
height="100%"
direction="column"
alignItems="center"
justifyContent={"center"}
>
{"画像を生成しています。。。"}
<Loader variation="linear" />
</Flex>
</Modal>
<Modal isOpen={modalToOpen == "ImageCreateSuccessView"}>
{imageUrl != "" && (
<ImageCreateSuccessView
overrides={{
"1700652527621-cat 1": { src: imageUrl },
Button: {
onClick: () => {
setModalToOpen("");
setImageUrl("");
},
},
prompt: { children: prompt },
}}
/>
)}
</Modal>
</>
);
}
Datastore, Figma to CodeのCollection
生成したプロンプトや画像の一覧はDatastoreに保存し、Figma to Codeで作ったコンポーネントを使って表示するようにしました。Collectionという、Figmaで作ったコンポーネントを複数表示する機能を使いました。
コード
"use client";
import { CreatedImage } from "@/models";
import ImageCardViewCollection from "@/ui-components/ImageCardViewCollection";
import { getSignedS3UrlFromKey } from "@/utils/createSaveImage";
import { DataStore, Predicates, SortDirection } from "aws-amplify/datastore";
import { useEffect, useState } from "react";
export default function App() {
const [url, setUrl] = useState("");
const [createdImages, setCreatedImages] = useState<CreatedImage[]>([]);
const fetchCreatedImage = async () => {
const resp = await DataStore.query(CreatedImage, Predicates.ALL, {
sort: (s) => s.createdAt(SortDirection.DESCENDING),
});
setCreatedImages([]);
const imgs: CreatedImage[] = [];
resp.forEach(async (item) => {
const urlResp = await getSignedS3UrlFromKey(item.s3Url);
await setUrl(urlResp);
const img: CreatedImage = new CreatedImage({
title: item.title,
s3Url: urlResp,
});
imgs.push(img);
});
setCreatedImages(imgs);
};
useEffect(() => {
fetchCreatedImage();
const subscription =
DataStore.observe(CreatedImage).subscribe(fetchCreatedImage);
return () => {
subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<ImageCardViewCollection
items={createdImages}
overrideItems={({ item, index }) => ({
overrides: { ImageCardView: { width: "100%" } },
})}
/>
</>
);
}
S3
生成した画像はAmplify経由でS3に格納し、一覧表示するときはSignedURLを取得するようにしました。
コード
export async function uploadBlobToS3(blob: Blob) {
const key = `${Date.now()}-cat.png`;
try {
const result = await uploadData({
key: key,
data: blob,
options: { accessLevel: "guest", contentType: "image/png" },
}).result;
console.log("Succeeded: ", result);
} catch (error) {
console.log("Error : ", error);
}
return key;
}
export async function getSignedS3UrlFromKey(key: string) {
const getUrlResult = await getUrl({
key: key,
options: { validateObjectExistence: false, expiresIn: 20 },
});
return getUrlResult.url.toString();
}
認証(パスワード、Googleログイン)
Amplifyでは認証はfirebase等と同じ感覚でかんたんに導入できます。GoogleログインはGCPとの間でAPI Key等を相互に参照するので、GUIで少し設定が必要です。
コード
return (
<Authenticator>
{children}
</Authenticator>
);
Nextjs 14
Amplify HostingでNextjs 14を使う場合は、Amazon Linux 2023をビルドイメージに使うだけです。Nextjs 14がリリースされてしばらくしてからサポートされました。
https://github.com/aws-amplify/amplify-hosting/issues/3773
Langchain, ChatGPT API
ここからはAmplifyとは別の今回使った技術です。最初にChatGPTにプロンプトを送って、ChatGPTに画像生成用のプロンプトを作ってもらいますが、ユーザーが入力した単語や数字等をプロンプトに埋め込むのにLangchainのLCEL (LangChain Expression Language)を使いました。
コード
"use server";
import { PromptTemplate } from "langchain/prompts";
import { ChatOpenAI } from "langchain/chat_models/openai";
export default async function createPrompt({
openApiKey,
generationTarget,
adjective,
languageOfPrompt,
numberOfWordsPrompt,
}: {
openApiKey: string;
generationTarget: string;
adjective: string;
languageOfPrompt: string;
numberOfWordsPrompt: string;
}) {
const model = new ChatOpenAI({ openAIApiKey: openApiKey });
console.log("languageOfPrompt:", languageOfPrompt);
const promptTemplate = PromptTemplate.fromTemplate(
`下記条件でDalleに出力させるためのプロンプトを考えてください。箇条書きではなく文章で
生成したいもの: {generationTarget}
どんな画像にしたいか: {adjective}
制約1:プロンプトのみを返してください。プロンプトの説明は不要です。
制約2:プロンプトは一つだけ作成してください。
制約3:プロンプトは出力が美しくなるように{numberOfWordsPrompt}単語前後で詳細に作成してください。
プロンプトの言語:{languageOfPrompt}
`
);
const chain = promptTemplate.pipe(model);
console.log(chain);
const result = await chain.invoke({
generationTarget: generationTarget,
adjective: adjective,
languageOfPrompt: languageOfPrompt,
numberOfWordsPrompt:
numberOfWordsPrompt && numberOfWordsPrompt != ""
? numberOfWordsPrompt
: "20",
});
console.log(result);
return String(result.content);
}
Dalle API
Ddalle APIはresponse_formatにb64_jsonを指定して、画像を取得しました。
コード
"use server";
import OpenAI from "openai";
export async function createDalleImageAsBase64({
openApiKey,
prompt,
}: {
openApiKey: string;
prompt: string;
}) {
const openai = new OpenAI({
apiKey: openApiKey,
});
const image = await openai.images.generate({
model: "dall-e-2",
prompt: prompt,
response_format: "b64_json",
size: "256x256",
});
const b64_json: string = image.data[0].b64_json ? image.data[0].b64_json : "";
return b64_json;
}
b64-to-blob
取得した画像をS3に格納する前に、blobに変換しました。
コード
export async function createFromDalleAndSaveToS3(
openApiKey: string,
prompt: string
) {
const b64_json = await createDalleImageAsBase64({
openApiKey: openApiKey,
prompt: prompt,
});
const blob = b64toblob(b64_json);
const key = await uploadBlobToS3(blob);
return key;
}
まとめ
AmpliifyやChatGPTのAPIを使うとスピーディーにアプリが作れるので、プロトタイピングだけでなくハッカソンにもお勧めです。