프롬프트로 이미지를 수정할 수 있는 AI 기반 이미지 편집기 구현 방법을 단계별로 알아봅니다.
AI를 사랑하는 여러분들을 위한 재미있는 가이드를 소개합니다. 오늘은 OpenAI의 DALL-E 2 모델로 구동되는 이미지 편집기를 만들어 보겠습니다. 이 이미지 편집기는 사용자가 사진을 업로드하고, 영역을 선택한 후 해당 영역을 프롬프트를 통해 편집할 수 있습니다. 추가로 이 앱은 사용자 계정과 함께 제공되며 Stripe 구독을 통해 결제를 할 수도 있습니다. 모든 코드는 오픈 소스이며 깃허브에서 사용할 수 있으니 마음껏 활용해보세요!
다음과 같은 스텝으로 진행됩니다.
Next.js를 시작하는 가장 쉬운 방법은 create-next-app
을 사용하는 것입니다. 이 CLI 도구를 사용하면 모든 설정이 완료되어 있는 새 Next.js 애플리케이션을 빠르게 구축할 수 있습니다.
$ npx create-next-app@latest
이미지 편집을 위해 DALL-E API를 사용할 것이므로 OpenAI API 키가 아직 없다면 키를 만들어야 합니다.
.env.local
파일을 만들고 그 안에 키를 붙여 넣겠습니다. 나중에 이 키를 사용하여 API 요청을 인증할 것입니다.
.env.local
OPENAI_KEY="sk-openAiKey"
ImageEditor
라는 컴포넌트를 만들어 홈페이지에 추가하는 것으로 시작해 봅시다. 나중에 브라우저 API를 사용할 것이기 때문에 "use client"
로 클라이언트 컴포넌트임을 표시하겠습니다.
// src/components/ImageEditor.tsx
"use client";
import { useState } from "react";
export default function ImageEditor() {
const [src, setSrc] = useState("");
return <div>{src && <img src={src} />}</div>;
}
ImageEditor를 app/page.tsx
에 가져옵니다.
import ImageEditor from "@/components/ImageEditor";
export default function Home() {
return <ImageEditor />;
}
편집 도구를 보관할 새 컴포넌트(Navigation)도 필요합니다. 업로드/다운로드 버튼과 이후엔 편집 생성 양식을 포함하는 컴포넌트입니다.
업로드/다운로드 버튼 구현은 react-icons
를 사용할 것이기 때문에 먼저 설치해 보겠습니다.
$ npm install react-icons
// src/components/Navigation.tsx
"use client";
import { useRef } from "react";
import { FiUpload, FiDownload } from "react-icons/fi";
import IconButton from "@/components/icons/IconButton";
interface Props {
onUpload?: (blob: string) => void;
onDownload?: () => void;
}
export default function Navigation({ onUpload, onDownload }: Props) {
const inputRef = useRef < HTMLInputElement > null;
const onUploadButtonClick = () => {
inputRef.current?.click();
};
const onLoadImage = (event: React.ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (files && files[0]) {
if (onUpload) {
onUpload(URL.createObjectURL(files[0]));
}
}
event.target.value = "";
};
return (
<div className="flex justify-between bg-slate-900 p-5">
<IconButton title="Upload image" onClick={onUploadButtonClick}>
<FiUpload />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={onLoadImage}
className="hidden"
/>
</IconButton>
<IconButton title="Download image" onClick={onDownload}>
<FiDownload />
</IconButton>
</div>
);
}
// src/components/icons/IconButton.tsx
import { ReactNode } from "react";
interface Props {
onClick?: () => void;
active?: boolean;
disabled?: boolean;
title?: string;
children: ReactNode;
}
export default function IconButton({
onClick,
active,
disabled,
title,
children,
}: Props) {
return (
<button
className={`w-[46px] h-[46px] flex items-center justify-center hover:bg-slate-300/10 rounded-full ${
active ? "text-sky-300 bg-slate-300/10" : "text-slate-300"
}`}
title={title}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
이 Navigation을 ImageEditor
에 추가하고 기본 업로드 및 다운로드 기능을 추가하겠습니다.
// src/components/ImageEditor.tsx
export default function ImageEditor() {
const [src, setSrc] = useState('');
const onUpload = (objectUrl: string) => {
setSrc(objectUrl);
};
const onDownload = async () => {
if (src) {
downloadImage(src);
}
};
const downloadImage = (objectUrl: string) => {
const linkElement = document.createElement("a");
linkElement.download = "image.png";
linkElement.href = objectUrl;
linkElement.click();
};
return (
<div>
{src && <img src={src}>}
<Navigation
onUpload={onUpload}
onDownload={onDownload}
/>
</div>
);
}
이제 애플리케이션을 시작하고 localhost:3000
을 열 수 있습니다.
$ npm run dev
아직까진 그다지 흥미롭진 않지만 이미지를 업로드하고 동일한 이미지를 컴퓨터로 다시 다운로드 할 수 있습니다. 이제부터 시작입니다.
DALL-E로 이미지 편집을 생성하려면 API(https://api.openai.com/v1/images/edits
)에 다음과 함께 요청을 보내야 합니다.
DALL-E API에 요청을 할 때는 인증을 위해 OpenAI API 키를 포함해야 합니다. API 키가 클라이언트에 노출되는 것을 방지하려면 해당 요청을 서버 측에서 처리해야 합니다. 이를 위해 src/app/images/edit에 라우트 핸들러를 설정하고 앞서 추가한 환경 변수 OPENAI_KEY
를 활용해 보겠습니다.
// src/app/images/edit/route.ts
export async function POST(request: Request) {
const apiKey = process.env.OPENAI_KEY;
const formData = await request.formData();
const res = await fetch("https://api.openai.com/v1/images/edits", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: formData,
});
const data = await res.json();
return Response.json(data);
}
이제 클라이언트 측에서 민감한 정보를 처리하지 않고 내부 API 라우트인 /images/edit
으로 요청할 수 있습니다.
이미지 생성에 대해서는 조금 후에 다시 설명하겠습니다. 현재 단계에서는 사용자가 이미지 마스크를 만들거나 프롬프트를 작성할 수 있는 방법이 아직 없습니다. 먼저 마스킹 문제를 해결하겠습니다. 이를 위해서는 몇 가지 방법이 있습니다.
DALL-E에 정사각형 이미지를 제공해야한다는 점을 염두에 두어야 합니다. 즉, 사용자가 직사각형 이미지를 업로드하는 경우 원하는 크기로 자를 수 있는 방법을 제공해야 합니다. 따라서 react-advanced-cropper를 사용하면 일석이조의 효과를 얻을 수 있습니다. 이 라이브러리는 기본적으로 자르기 기능을 제공하며, 라이브러리에서 제공하는 선택도구로 마스크를 만드는 데에 사용할 수 있습니다. 우선 설치해 봅시다.
$ npm install react-advanced-cropper
이제 ImageEditor
에 크롭퍼(cropper)를 추가할 수 있습니다. mode
(자르기/생성)에 대한 상태 변수를 추가하겠습니다. 새 이미지를 업로드하면 편집기는 "자르기" 모드로 들어갑니다. 사용자가 이미지를 자른 후에는 사용자가 선택하고 프롬프트를 입력할 수 있게 하는 "생성"모드로 진행합니다.
// src/components/ImageEditor.tsx
"use client";
import { useState, useRef } from "react";
import {
FixedCropperRef,
FixedCropper,
ImageRestriction,
} from "react-advanced-cropper";
import "react-advanced-cropper/dist/style.css";
import Navigation from "@/components/Navigation";
export default function ImageEditor() {
const cropperRef = useRef<FixedCropperRef>(null);
const [src, setSrc] = useState("");
const [mode, setMode] = useState("crop");
const isGenerating = mode === "generate";
const crop = async () => {
const imageSrc = await getCroppedImageSrc();
if (imageSrc) {
setSrc(imageSrc);
setMode("generate");
}
};
const onUpload = (imageSrc: string) => {
setSrc(imageSrc);
setMode("crop");
};
const onDownload = async () => {
if (isGenerating) {
downloadImage(src);
return;
}
const imageSrc = await getCroppedImageSrc();
if (imageSrc) {
downloadImage(imageSrc);
}
};
const downloadImage = (objectUrl: string) => {
const linkElement = document.createElement("a");
linkElement.download = "image.png";
linkElement.href = objectUrl;
linkElement.click();
};
const getCroppedImageSrc = async () => {
if (!cropperRef.current) return;
const canvas = cropperRef.current.getCanvas({
height: 1024,
width: 1024,
});
if (!canvas) return;
const blob = (await getCanvasData(canvas)) as Blob;
return blob ? URL.createObjectURL(blob) : null;
};
const getCanvasData = async (canvas: HTMLCanvasElement | null) => {
return new Promise((resolve, reject) => {
canvas?.toBlob(resolve);
});
};
return (
<div className="w-full bg-slate-950 rounded-lg overflow-hidden">
{isGenerating ? (
<img src={src} />
) : (
<FixedCropper
src={src}
ref={cropperRef}
className={"h-[600px]"}
stencilProps={{
movable: false,
resizable: false,
lines: false,
handlers: false,
}}
stencilSize={{
width: 600,
height: 600,
}}
imageRestriction={ImageRestriction.stencil}
/>
)}
<Navigation
mode={mode}
onUpload={onUpload}
onDownload={onDownload}
onCrop={crop}
/>
</div>
);
}
react-fixed-cropper
가 제공하는 FixedCropper
로 이미지를 자를 수 있습니다. 잘린 이미지가 포함된 HTML 캔버스 요소를 반환하는 getCanvas()
함수에 접근하기 위해 cropperRef
를 활용할 수 있습니다. Navigation에 Crop 버튼을 추가하겠습니다. 사용자가 Crop 버튼을 클릭하면 캔버스에서 결과를 읽고 src
의 상태를 업데이트한 후 "생성" 모드로 전환합니다.
// src/components/Navigation.tsx
return (
<div className="flex justify-between bg-slate-900 p-5">
<IconButton title="Upload image" onClick={onUploadButtonClick}>
<FiUpload />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={onLoadImage}
className="hidden"
/>
</IconButton>
<div className="flex grow items-center justify-center gap-2 mx-20">
{mode === "crop" && <Button onClick={onCrop}>Crop</Button>}
</div>
<IconButton title="Download image" onClick={onDownload}>
<FiDownload />
</IconButton>
</div>
);
결과는 다음과 같습니다.
이미지 자르기가 완료되면 마스킹 단계로 진행할 수 있습니다. 여기서는 react-advanced-cropper
의 표준 Cropper를 사용하겠습니다. 새로운 ImageSelector
컴포넌트를 만들어 보겠습니다.
// src/components/ImageSelector.tsx
"use client";
import {
Cropper,
CropperRef,
Coordinates,
ImageSize,
} from "react-advanced-cropper";
interface Props {
src: string;
selectionRect?: Coordinates | null;
onSelectionChange: (cropper: CropperRef) => void;
}
export default function ImageSelector({
src,
selectionRect,
onSelectionChange,
}: Props) {
const defaultCoordinates = ({ imageSize }: { imageSize: ImageSize }) => {
return (
selectionRect || {
top: imageSize.width * 0.1,
left: imageSize.width * 0.1,
width: imageSize.width * 0.8,
height: imageSize.height * 0.8,
}
);
};
return (
<Cropper
src={src}
className={"h-[600px]"}
stencilProps={{
overlayClassName: "cropper-overlay",
}}
backgroundWrapperProps={{
scaleImage: false,
moveImage: false,
}}
defaultCoordinates={defaultCoordinates}
onChange={onSelectionChange}
/>
);
}
이 selector는 꽤 직관적입니다. 이미지 URL, 좌표를 저장할 함수인 onSelectionChange
와 좌표를 저장할 객체인 selectionRect
을 받습니다. 이 좌표를 활용해 이미지 마스크를 생성할 것입니다.
ImageEditor
의 <img/>
를 새로 생성한 ImageSelector
로 바꾸고 selectionRect
와 onSelectionChange
를 구현해 보겠습니다.
// src/components/ImageEditor.tsx
const [selectionRect, setSelectionRect] = useState<Coordinates | null>();
const onSelectionChange = (cropper: CropperRef) => {
setSelectionRect(cropper.getCoordinates());
};
...
return (
...
{isGenerating ? (
<ImageSelector
src={src}
selectionRect={selectionRect}
onSelectionChange={onSelectionChange}
/>
) : (
<FixedCropper ... />
)}
...
)
이제 이미지의 일부를 쉽게 선택할 수 있습니다.
이제 DALL-E로 전송할 image
와 mask
를 만드는 작업만 하면 됩니다. 이미 잘라낸 이미지가 src
에 있으므로 캔버스에 그려서 Blob으로 변환하기만 하면 됩니다. 마스크는 캔버스의 2D 컨텍스트의 globalCompositeOperation
속성을 "destination-out"으로 설정한 다음 이미지 위에 선택할 사각형을 그리면 됩니다.
// src/components/ImageEditor.tsx
const getImageData = async () => {
if (!src) return;
const canvas = document.createElement("canvas");
await drawImage(canvas, src);
return getCanvasData(canvas);
};
const getMaskData = async () => {
if (!src || !selectionRect) return;
const canvas = document.createElement("canvas");
await drawImage(canvas, src);
drawMask(canvas, selectionRect);
return getCanvasData(canvas);
};
const drawImage = (canvas: HTMLCanvasElement | null, src: string) => {
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const width = img.width;
const height = img.height;
canvas.width = width;
canvas.height = height;
context.drawImage(img, 0, 0, width, height);
resolve(context);
};
img.src = src;
});
};
const drawMask = (
canvas: HTMLCanvasElement | null,
rect: Coordinates | null
) => {
const context = canvas?.getContext("2d");
if (!context || !rect) return;
context.globalCompositeOperation = "destination-out";
context.fillRect(rect.left, rect.top, rect.width, rect.height);
};
거의 다 왔습니다. 이제 사용자가 프롬프트를 작성할 수 있는 방법을 추가하는 일만 남았습니다. 입력 필드와 버튼이 있는 간단한 양식을 구현할 것입니다. 이 양식을 통해 사용자가 프롬프트를 입력하면 DALL-E API로 전송해 이미지 편집을 생성할 수 있습니다.
// src/components/GenerateImage.tsx
"use client";
import { useState } from "react";
import Input from "@/components/Input";
import Button from "@/components/Button";
interface Props {
getImageData: () => Promise<any>;
getMaskData: () => Promise<any>;
onGenerate?: (blob: Blob, prompt: string) => void;
}
export default function GenerateImage({
getImageData,
getMaskData,
onGenerate,
}: Props) {
const [prompt, setPrompt] = useState("");
const canGenerate = !!prompt;
const onPromptChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPrompt(e.target.value);
};
const generate = async () => {};
return (
<div className="flex flex-col md:flex-row gap-2">
<Input type="text" onChange={onPromptChange} />
<Button disabled={!canGenerate} onClick={generate}>
Generate
</Button>
</div>
);
}
//src/components/Input.tsx
"use client";
interface InputProps {
type?: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function Input({ type = "text", onChange }: InputProps) {
return (
<input
type={type}
onChange={onChange}
className="bg-transparent border border-gray-300 text-slate-300 rounded-lg min-w-0 px-5 py-2.5"
/>
);
}
// src/components/Button.tsx
import { ReactNode } from "react";
interface ButtonProps {
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}
export default function Button({
onClick,
disabled = false,
children,
}: ButtonProps) {
return (
<button
className="text-gray-900 bg-white border border-gray-300 disabled:bg-gray-300 focus:outline-none enabled:hover:bg-gray-100
focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5
dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:enabled:hover:bg-gray-700 dark:enabled:hover:border-gray-600
dark:focus:ring-gray-700"
disabled={disabled}
onClick={() => onClick()}
>
{children}
</button>
);
}
Navigation
에 이 양식을 추가해 보겠습니다.
// src/components/Navigation.tsx
return (
<div className="flex justify-between bg-slate-900 p-5">
<IconButton title="Upload image" onClick={onUploadButtonClick}>
<FiUpload />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={onLoadImage}
className="hidden"
/>
</IconButton>
<div className="flex grow items-center justify-center gap-2 mx-20">
{mode === "crop" && <Button onClick={onCrop}>Crop</Button>}
{mode === "generate" && (
<GenerateImage
getImageData={getImageData}
getMaskData={getMaskData}
onGenerate={onGenerateImage}
/>
)}
</div>
<IconButton title="Download image" onClick={onDownload}>
<FiDownload />
</IconButton>
</div>
);
이제 이 양식을 사용해서 이미지 편집을 위한 프롬프트를 입력할 수 있습니다.
이제 GenerateImage
의 generate
기능을 구현할 수 있습니다. 내부 API 경로인 /images/edit
에 image
,mask
와 prompt
를 전달하며 요청합니다. 이미지를 전송하기 위해서는 formData
객체를 사용해야 합니다. ImageEditor
에서 getImageData
와 getMaskData
를 호출하여 원본 이미지와 마스크를 가져올 수 있습니다. 여기서 추가해야 할 데이터는 사용자가 입력한 프롬프트뿐입니다.
// src/components/GenerateImage.tsx
const generate = async () => {
const image = (await getImageData()) as Blob;
const mask = (await getMaskData()) as Blob;
if (!image || !mask) return;
const formData = new FormData();
formData.append("image", image);
formData.append("mask", mask);
formData.append("prompt", prompt);
formData.append("response_format", "b64_json");
let result, response;
try {
response = await fetch("/images/edit", {
method: "POST",
body: formData,
});
result = await response.json();
if (result.error) {
throw new Error(result.error.message);
}
const imageData = result.data[0].b64_json;
const blob = dataURLToBlob(imageData, "image/png");
if (onGenerate) {
onGenerate(blob, prompt);
}
} catch (e) {}
const dataURLToBlob = (dataURL: string, type: string) => {
var binary = atob((dataURL || "").trim());
var array = new Array(binary.length);
for (let i = 0; i < binary.length; i++) {
array[i] = binary.charCodeAt(i);
}
return new Blob([new Uint8Array(array)], { type });
};
};
이미지 편집이 성공적으로 생성되면 ImageEditor
에서 onGenerate
함수를 호출해 src
의 상태를 업데이트합니다. 선택 내용은 유지되기 때문에 사용자는 원한다면 또 다른 편집 내용을 만들 수 있습니다.
// src/components/ImageEditor.tsx
const onGenerate = (imageSrc: string, prompt: string) => {
setSrc(imageSrc);
};
이미지 편집을 생성해 봅시다. DALL-E가 선택 영역 안에 풍선 플라밍고를 그렸고 이미지의 다른 부분은 건드리지 않은 것을 볼 수 있습니다.
원문에서는 reflow를 사용하여 로그인과 구독을 구현하는 방법이 이하 서술되어 있습니다. 해당 글이 reflow에서 발행한 글이다 보니 추가되어 있는 부분으로 보여 따로 번역을 진행하지 않았습니다. 관심있으신 분들은 원문 링크를 통해 확인해주시길 바랍니다!