컴포넌트를 이미지로 변환하기(Satori+resvg-js, @vercel/og)

비얌·2024년 10월 19일
0
post-thumbnail

✨ 개요

사이드 프로젝트를 하다가, 이미지 저장 기능을 만들어야 할 일이 생겼다. 폴라로이드 모양의 컴포넌트를 이미지로 저장해야 했다.

그리고 이미지로 저장하려면 컴포넌트를 이미지로 변환해야 했다.

그래서 컴포넌트를 이미지(png)로 변환하려고 시도해봤는데, 그 과정에서 겪은 시행착오들을 기록으로 남겨보려고 한다.



👏 결과 미리보기

컴포넌트가 png 형식으로 성공적으로 바뀌었다👍



🛫 전체 코드

1. Satori + resvg-js

전체 코드는 아래와 같다. 아래 블로그를 많이 참고했다.
참고 : https://medium.com/@zero86/%EA%B0%9C%EB%B0%9C%EC%82%BD%EC%A7%88-satori-83c0d56c2a95

컴포넌트 => 이미지 변환한 걸 렌더링하는 페이지

// app/satori/page.tsx
'use client';
import { useEffect, useState } from 'react';

export default function Page() {
  const [imgSrc, setImgSrc] = useState('');

  useEffect(() => {
    // 쿼리 파라미터 생성
    const query = new URLSearchParams({
      date: '2024-02-02',
      text: '오늘은 불꽃축제에 다녀왔다ㅎㅎ 너무 예뻤다!! 친구와 함께 여의도에 갔다~',
      uploadedImageUrl:
        '이미지url',
    }).toString();

    // img src를 API 경로로 설정
    setImgSrc(`/api/satori?${query}`);
  }, []);

  return (
    <div>
      {/* API로부터 받은 이미지(png)를 렌더링 */}
      {imgSrc && <img src={imgSrc} alt="Generated PNG by satori+resvg" />}
    </div>
  );
}

컴포넌트 => 이미지 변환 api

// app/api/satori/route.tsx
import satori from 'satori';
import fs from 'fs';
import path from 'path';
import { NextResponse } from 'next/server';
// convertSvgToPngByResvg : 아래에 코드 있음
import { convertSvgToPngByResvg } from '@/app/utils/convertSvgToPngByResvg';

// 폰트 로드
const pretendardFontBuffer = fs.readFileSync(
  path.join(process.cwd(), 'src', 'app', 'fonts', 'Pretendard-Regular.woff')
);

const timeFontBuffer = fs.readFileSync(
  path.join(process.cwd(), 'src', 'app', 'fonts', 'time.ttf')
);

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const date = searchParams.get('date') || '';
  const text = searchParams.get('text') || '';
  const uploadedImageUrl = searchParams.get('uploadedImageUrl') || '';

  try {
    // Satori로 SVG 생성
    const svg = await satori(
      <div
        style={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          position: 'relative',
          height: '100vh',
        }}
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'center',
            alignItems: 'center',
            position: 'relative',
          }}
        >
          <div style={{ display: 'flex', position: 'relative' }}>
            <img
              src="/frame.jpg"
              alt="폴라로이드"
              style={{ height: '516px', width: '324px' }}
            />
          </div>
          <img
            src={uploadedImageUrl}
            alt="이미지"
            style={{
              display: 'flex',
              objectFit: 'contain',
              position: 'absolute',
              top: '43px',
              left: '54px',
              height: '382px',
              width: '288px',
            }}
          />
          <div
            style={{
              display: 'flex',
              position: 'absolute',
              bottom: '154px',
              right: '90px',
              fontFamily: 'timeFont',
              color: '#facc15',
            }}
          >
            <span>{date}</span>
          </div>
          <div
            style={{
              display: 'flex',
              width: '100%',
              position: 'relative',
              bottom: '70px',
              right: '-50px',
            }}
          >
            <div
              style={{
                display: 'flex',
                width: '100%',
                padding: '8px',
                zIndex: 50,
                backgroundColor: 'transparent',
                resize: 'none',
                fontSize: '15px',
                color: '#1F2937',
                maxWidth: '300px',
              }}
            >
              {text}
            </div>
          </div>
        </div>
      </div>,
      {
        width: 400,
        height: 500, 
        fonts: [
          {
            style: 'normal',
            name: 'pretendard',
            data: pretendardFontBuffer,
            weight: 600,
          },
          {
            style: 'normal',
            name: 'timeFont',
            data: timeFontBuffer,
            weight: 600,
          },
        ],
      }
    );

    const pngBuffer = convertSvgToPngByResvg(svg);

    // 성공적으로 SVG를 생성한 후 반환
    return new NextResponse(pngBuffer, {
      status: 200,
      headers: {
        'Content-Type': 'image/png',
      },
    });
  } catch (error) {
    console.error('SVG 생성 오류:', error);
    return new NextResponse('SVG 생성 오류', { status: 500 });
  }
}

svg를 png로 변환하는 코드(resvg-js)

// app/utils/convertSvgToPngByResvg.ts
import { Resvg } from '@resvg/resvg-js';

export function convertSvgToPngByResvg(targetSvg: Buffer | string) {
  const resvg = new Resvg(targetSvg, {});
  const pngData = resvg.render();
  return pngData.asPng();
}

2. @vercel/og

전체 코드는 아래와 같다.

컴포넌트 => 이미지 변환한 걸 렌더링하는 페이지

// app/og/page.tsx
export default function Page() {
  const date = '2024-02-02';
  const text =
    '오늘은 불꽃축제에 다녀왔다ㅎㅎ 너무 예뻤다!! 친구와 함께 여의도에 갔다~';
  const uploadedImageUrl = '이미지 url';
  return (
    <div>
      <img
        src={`http://localhost:3000/api/og?date=${date}&text=${text}&uploadedImageUrl=${uploadedImageUrl}`}
        alt="Generated PNG by @vercel/og"
      />
    </div>
  );
}

ImageResponse로 이미지 생성

공식문서를 참고하여 ImageResponse로 이미지를 생성했다.
참고 : https://vercel.com/docs/functions/og-image-generation

// // app/api/og/route.tsx
import { ImageResponse } from 'next/og';
// 공식문서 주석
// App router includes @vercel/og.
// No need to install it.
import fs from 'fs';
import path from 'path';

// 폰트 로드
const pretendardFontBuffer = fs.readFileSync(
  path.join(process.cwd(), 'src', 'app', 'fonts', 'Pretendard-Regular.woff')
);

const timeFontBuffer = fs.readFileSync(
  path.join(process.cwd(), 'src', 'app', 'fonts', 'time.ttf')
);

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const date = searchParams.get('date') || '';
  const text = searchParams.get('text') || '';
  const uploadedImageUrl = searchParams.get('uploadedImageUrl') || '';

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          position: 'relative',
          height: '100vh',
        }}
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'center',
            alignItems: 'center',
            position: 'relative',
          }}
        >
          <div style={{ display: 'flex', position: 'relative' }}>
            <img
              src="이미지 url"
              alt="폴라로이드"
              style={{ height: '516px', width: '324px' }}
            />
          </div>
          <img
            src={uploadedImageUrl}
            alt="이미지"
            style={{
              display: 'flex',
              objectFit: 'contain',
              position: 'absolute',
              top: '43px',
              left: '54px',
              height: '382px',
              width: '288px',
            }}
          />
          <div
            style={{
              display: 'flex',
              position: 'absolute',
              bottom: '154px',
              right: '90px',
              fontFamily: 'timeFont',
              color: '#facc15',
            }}
          >
            <span>{date}</span>
          </div>
          <div
            style={{
              display: 'flex',
              width: '100%',
              position: 'relative',
              bottom: '70px',
              right: '-50px',
            }}
          >
            <div
              style={{
                display: 'flex',
                width: '100%',
                padding: '8px',
                zIndex: 50,
                backgroundColor: 'transparent',
                resize: 'none',
                fontSize: '15px',
                color: '#1F2937',
                maxWidth: '300px',
              }}
            >
              {text}
            </div>
          </div>
        </div>
      </div>
    ),
    {
      width: 400,
      height: 500,
      fonts: [
        {
          style: 'normal',
          name: 'pretendard',
          data: pretendardFontBuffer,
          weight: 600,
        },
        {
          style: 'normal',
          name: 'timeFont',
          data: timeFontBuffer,
          weight: 600,
        },
      ],
    }
  );
}


💥 시행착오

html-to-png 라이브러리

사실 Satori와 @vercel/og를 쓰기 전에 html-to-png라는 라이브러리를 썼었다. 컴포넌트 자체를 코드 변형 없이 이미지로 다운로드 할 수 있게 해준다고 한다.

그런데 큰 오류가 있었다. 예를 들어 다운로드가 되긴 하는데 3의 배수번째에서만 정상적으로 이미지가 뽑혔다. 이 오류를 해결하지 않는 한 쓸 수 없다고 판단했다.

아래는 3의 배수번째에만 정상적으로 이미지가 뽑혔던 경우의 예시이다. 오류를 해결하지 못해서 결국 Satori와 같은 다른 방법을 찾게 되었다.

Satori에서 woff2폰트를 쓸 때 오류가 남

satori에서 woff2 폰트를 쓰면 오류가 난다. 그래서 woff나 ttf인 폰트를 써봤더니 잘 됐다.

찾아보니 woff2를 지원하지 않는다고 한다.

Currently opentype.js doesn't support woff2, which is usually 30% the size of woff. Fontkit might be a good alternative for this (but the lib is larger). / Decided to put a lower priority on this. TTF & OTF are faster to load and parse, so on the server side we will always recommend using them unless there's a size limitation. WOFF is a good balance of size and parsing speed (smaller but slightly longer to load).

참고 : https://github.com/vercel/satori/issues/3

resvg 사용시 에러 : Module parse failed: Unexpected character '�'

resvg 사용시에 자꾸 Module parse failed: Unexpected character '�'라는 오류가 나는데 검색해봐도 해결방법을 찾을 수 없었다.

그래서 개발방에 질문을 올렸고, 해결 방법을 얻을 수 있었다. next config 파일에 아래 코드를 추가하면 된다.

// next.config.js

/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ["@resvg/resvg-js"],
  },
};
module.exports = nextConfig;
  1. https://github.com/yisibl/resvg-js/issues/198#issuecomment-1738956014
  2. https://github.com/vercel/next.js/issues/59648#issuecomment-1857686907

해결 방법을 알려주신 분이 남겨주신 코멘트는 아래와 같다. 디버깅 과정을 자세하게 설명해주셨다.

디버깅 과정
Module parse failed: Unexpected character '�' 라는 에러가 발생했다
저 물음표 아이콘은 유효한 유니코드 문자가 아닐 때 나오는 문자임, 즉 뭔가 텍스트가 아닌 파일을 쓰려고 하고 있음
근데 그 파일이 뭘까? 에러 스크린샷을 보면 resvgjs.win32-x64-msvc.node 파일을 불러오다가 난 에러인 걸 알 수 있음
.node 파일은 Node.js에서 Native Addons를 만드는 데 쓰는 파일임
그런데 에러 내용은 appropriate loader를 못 찾았다는 내용, 즉 Webpack에서 발생한 에러임
Node.js에서 처리해야 하는 파일을 Webpack이 읽고 있음

배경 지식
Node.js Native Addons: JavaScript가 아니라 C/C++/Rust 등의 언어로 작성되어 바이너리로 컴파일된 모듈이에요. 주로 성능 향상을 위해 쓰이고, 바이너리라는 특성 상 각 플랫폼마다 다른 파일을 준비해야 해요 (그래서 위 에러에서도 win32라는 게 보임)

해결 방법
Webpack이 아니라 Node.js가 해당 파일을 처리하게 해야 함 -> Webpack이 해당 파일을 처리하지 못하는 방법을 찾아내자
Webpack 등 번들러가 모듈을 처리하지 않고 그대로 두길 원하는 경우, externals에 해당 모듈을 추가할 수 있습니다
근데 Next.js는 직접 Webpack 설정을 관리하니 직접 건드리는 건 안 좋음 -> 열어둔 다른 방법이 있을 것이다
nextjs native modules in app router 검색하고 생각한거랑 비슷한 해결책 찾아보다가 저 이슈 찾음

화면에 보이는 깨진 문자들

갑자기 화면에 이미지가 아니라 깨진 문자들이 보이기 시작했다. 이건 Content-Type을 image/png가 아니라 svg로 설정했기 때문이었다. Content-Type을 image/png로 설정하고 해결되었다.

return new NextResponse(pngBuffer, {
  status: 200,
  headers: {
    'Content-Type': 'image/png',
  },
});

Vercel 배포 오류 : Can't deploy to Vercel - error - Route does not match the required types of a Next.js Route

찾아보니 한 파일에 export가 두개일 때 나타날 수 있는 오류라고 한다. 그래서 따로 파일을 분리했더니 해결되었다.

I also had more than one export on a page.tsx file which seems like that was the issue. Moving the 2nd export to it's own file solved the issue.

참고 : https://www.reddit.com/r/nextjs/comments/1achrkw/cant_deploy_to_vercel_error_route_does_not_match/

SVG 생성 오류 : Error: Cannot access Image.toString on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.

이 오류가 해결이 안됐었다. 그래서 Satori + resvg를 포기하고 @vercel/og로 넘어갔다. 그런데도, 같은 오류가 났다. 그래서 열심히 디버깅을 한 결과... 서버 컴포넌트(src/app/api/satori/route.tsx, src/app/api/og/route.tsx)에서 Image(next/image)를 써서 그랬다. Image를 img태그로 바꾸니 해결되었다.

찾아보니 next/image는 클라이언트사이드에서만 사용할 수 있다고 한다.
참고 : https://github.com/vercel/next.js/issues/41924



✨ 결과

컴포넌트가 png 형식으로 바뀌었다👍



🐹 회고

이미지 변환을 html-to-png 라이브러리로 쉽게 할 수 있을 줄 알았다. 그런데 예상외의 버그로 되지 않았고, 그래서 Satori + resvg-js를 쓰게 되고.. 그런데 오류가 나서 @vercel/og를 쓰게되고..

NextJS App router를 이번 프로젝트에서 처음 써보며 @vercel/og도 처음 알게 되었는데, NextJS App router에 내장되어있는 기능이라니 신기했다.

그리고 Satori도 말로만 들었었고 해보기 전에는 막막했지만 다행히 한국어로 된 블로그가 하나 있어서 이정표로 잡고 할 수 있었다. 관련 자료를 잘 찾지 못했는데, 검색을 잘못했나 싶다. 다음부터는 한국어 자료가 없다면 영문 자료도 필수적으로 찾아봐야겠다.

이번에는 오류가 참 많았다. 해결될라치면 또 오류가 나고 해결하면 또 오류가 나고.. 그래도 포기하지 않고 질문방에 질문도 올리고, 열심히 디버깅한 점을 칭찬해주고 싶다. 배경지식이 하나도 없어서 많이 헤맸는데 시행착오를 거치면서 조금씩 알아갈 수 있어 뿌듯했다.

질문을 올렸을 때 한 분이 해결 방법과 함께 디버깅 과정을 상세히 공유해주셔서 너무 감사했다. 그분의 디버깅 과정을 읽어보니 배경지식이 중요한 것 같다고 느꼈다.

이미지로 변환했으니, 다음번에는 이미지 다운로드 기능을 만들어야 한다. 다음 과정도 화이팅!!

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹
post-custom-banner

0개의 댓글