[번역] DALL-E와 Next.js로 이미지 편집기 만들기

eunbinn·2024년 4월 22일
14

FrontEnd 번역

목록 보기
31/31
post-thumbnail

출처: https://reflowhq.com/learn/image-editor-dall-e-next/

프롬프트로 이미지를 수정할 수 있는 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)에 다음과 함께 요청을 보내야 합니다.

  • image: 편집할 이미지입니다. 4MB 미만의 유효한 PNG 파일이어야 하며 정사각형이어야 합니다. 마스크가 제공되지 않으면 이미지에 투명도가 있어야 하며, 이 투명도가 마스크로 사용됩니다.
  • prompt: 원하는 이미지에 대한 텍스트 설명입니다. 최대 길이는 1000자입니다.
  • mask: 편집하고자 하는 위치를 완전히 투명한 영역(예: 알파 값이 0인 곳)으로 나타낸 추가 이미지입니다. 4MB 미만의 유효한 PNG 파일이어야 하며 image와 크기가 같아야 합니다.

요청 처리하기

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으로 요청할 수 있습니다.

이미지 자르기

이미지 생성에 대해서는 조금 후에 다시 설명하겠습니다. 현재 단계에서는 사용자가 이미지 마스크를 만들거나 프롬프트를 작성할 수 있는 방법이 아직 없습니다. 먼저 마스킹 문제를 해결하겠습니다. 이를 위해서는 몇 가지 방법이 있습니다.

  1. 캔버스에 이미지를 그리고 지우개 도구를 구현해서 사용자가 다시 만들고자 하는 부분을 삭제할 수 있도록 합니다.
  2. 선택 도구를 추가해 사용자가 지우고자 하는 이미지의 부분에 선택 도구를 그려 지울 수 있도록 합니다.

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로 바꾸고 selectionRectonSelectionChange를 구현해 보겠습니다.

// 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로 전송할 imagemask를 만드는 작업만 하면 됩니다. 이미 잘라낸 이미지가 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>
);

이제 이 양식을 사용해서 이미지 편집을 위한 프롬프트를 입력할 수 있습니다.

편집 생성하기

이제 GenerateImagegenerate 기능을 구현할 수 있습니다. 내부 API 경로인 /images/editimage,maskprompt를 전달하며 요청합니다. 이미지를 전송하기 위해서는 formData 객체를 사용해야 합니다. ImageEditor에서 getImageDatagetMaskData를 호출하여 원본 이미지와 마스크를 가져올 수 있습니다. 여기서 추가해야 할 데이터는 사용자가 입력한 프롬프트뿐입니다.

// 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에서 발행한 글이다 보니 추가되어 있는 부분으로 보여 따로 번역을 진행하지 않았습니다. 관심있으신 분들은 원문 링크를 통해 확인해주시길 바랍니다!

0개의 댓글