AI 탐구노트

나만의 썸네일 메이커 만들기 - 1편 본문

DIY 테스트

나만의 썸네일 메이커 만들기 - 1편

42morrow 2024. 10. 9. 12:08

배경 사진: Unsplash 의 Luke Jones

 

 

블로그 글을 써보니 아무런 이미지 없이 글만 덩그러니 있으면 영 없어 보입니다.

사실 Thumbnail 이미지는 대부분 비슷합니다.

파워포인트 표지 슬라이드처럼 말이죠.

하지만, 이런 것들을 잘 사용하면 블로그나 카페의 글이 훨씬 더 풍부함을 얻을 수 있습니다.

그래서, 최근엔 Thumbnail Maker를 사용해서 이미지를 생성하고 이걸 블로그 글머리에 넣었었습니다. 

 

아쉬운 점

 

사진 : 원옥님의 Thumbnail Maker 화면

 

 

이 솔루션이 심플하고 편하고 이쁘고...

다른 건 다 편하고 좋은데 한 가지 아쉬운 부분이 있었습니다. 

이미지 URL에 제가 자주 이용하는 Unsplash의 이미지 URL을 입력하면

보이는 화면 상에는 반영된 이미지가 나오는데 

'완료 및 이미지화' 버튼을 눌러 이미지 파일로 열어보면

배경이 비어있는 상태의 이미지만 나온다는 것입니다. 

 

 

그래서, 별도로 나만의 툴을 만들어 보기로 했습니다. 

 

 

요구사항

제가 필요한 기능은 다음과 같았습니다. 

  • 제목, 세부제목, 분류를 텍스트로 입력하면 바로 반영되어야 한다.
  • 제목, 세부제목, 분류는 각각 다른 색상을 입힐 수 있어야 한다.
  • 배경 이미지를 변경할 수 있어야 한다. 다만, url 대신 파일 업로드나 클립보드를 사용하면 좋겠다.
  • 배경 이미지가 없을 경우, 그라디언트 색상 변화를 적용한다. 단, 세로 방향만 하진 않았으면 좋겠다.
  • 서버/클라이언트 방식이 아닌 단독 실행 방식의 도구면 좋겠다.

 

대략 이 정도였던 것 같습니다. 

위 내용 가운데 취소선이 적용된 것은 아래에 언급하겠지만 시행착오 끝에 폐기된 것입니다. -_-;

 

 

시행착오

 

이걸 만들기 위해 진행했던 시행착오는 다음과 같습니다.

 

시행착오-1.어플리케이션 방식 선정

 

Python GUI 이용 -> Electron 어플 -> Python (FastAPI) + HTML + CSS + Javascript 

 

원래 단독으로 돌아가는 프로그램을 원했고 시도해 본 것은 위의 순서대로 였습니다. 

  • Python 자체 GUI를 만들 때 Thinker를 이용했는데 너무 멋짐이 없었습니다. 
  • Electron 방식은 UI는 괜찮은데, 보안 규정 때문인지 제약이 많았습니다. 

결국 최종적으로는 Python(FastAPI)는 서버로만 사용하고

HTML/CSS/Javascript로 화면과 기능을 구현하는 것으로 바뀌었습니다. 

 

시행착오-2.캔버스에 글자 중복 찍힘

 

아래 화면처럼 글자가 여러번 중복해서 찍히는 현상이 발생했습니다.

캔버스의 내용이 초기화되지 않은 상태로 그대로 덧붙여져 발생한 것이었습니다. 

그 뒤 비슷한 현상이 한번 더 있었는데 이는 그라디언트와 이미지, 텍스트 그리기의 순서 때문에 발생했었습니다. 

 

 

 

시행착오-3.클립보드 이미지 붙여넣기 

 

저는 이미지를 복사해서 배경으로 붙여 넣는 기능으로 구현하고 싶었습니다.

ChatGPT가 구현된 코드로 되어야 할 것 같은데 안 되고 계속해서 막히더군요.

결국은 클립보드를 다루는 대신 이미지 파일을 Drag & Drop 하는 방식으로 변경했습니다. 

 

Drag & Drop 시, 새탭에서 열리는 문제가 발생했는데 

브라우저의 기본 동작을 제한하는 방식으로 접근했었습니다. 

그런데 거기서도 막힘... 뭐지 싶었는데... 

혹시나 하는 마음에 브라우저의 개발자 도구 창을  닫았더니 되더군요. -_-;

 

일단 여기까지 온 마당에 다시 이전으로 돌아갈 수도 없고 해서... 

그대로 Drag & Drop 방식으로 진행했습니다. 

 

코드

python의 FastAPI를 서버로 만들고, html, css, javascript로 화면을 생성하는 구성입니다.

전체 파일 구조는 다음과 같습니다. 

 

사진 : 파일구조

 

 

코드 : main.py

 

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

app = FastAPI()

# 정적 파일 제공 (HTML, CSS, JS)
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/")
def read_root():
    return FileResponse("static/index.html")

 

 

 

코드 : index.html

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Thumbnail Maker</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <h1>Thumbnail Maker</h1>
        <div class="thumbnail-preview">
            <canvas id="thumbnailCanvas" width="800" height="400"></canvas>
        </div>
        <div class="controls">
            <!-- 제목 관련 입력 필드 -->
            <div class="input-group">
                <label for="title">제목:</label>
                <input type="text" id="title" placeholder="메일 제목">
                <input type="color" id="titleColor" value="#FFFFFF"> <!-- 색상 선택기 -->
            </div>

            <!-- 세부제목 관련 입력 필드 -->
            <div class="input-group">
                <label for="subtitle">세부제목:</label>
                <input type="text" id="subtitle" placeholder="세부 제목">
                <input type="color" id="subtitleColor" value="#FFFFFF"> <!-- 색상 선택기 -->
            </div>

            <!-- 분류 관련 입력 필드 -->
            <div class="input-group">
                <label for="category">분류:</label>
                <input type="text" id="category" placeholder="분류">
                <input type="color" id="categoryColor" value="#FFFFFF"> <!-- 색상 선택기 -->
            </div>

            <button id="generate">랜덤 그라디언트 생성</button>
            <button id="save">이미지 저장</button>
        </div>

        <div class="drag-drop-area" id="dragDropArea">
            <p>여기에 이미지를 끌어다 놓으세요</p>
        </div>
    </div>

    <script src="/static/app.js"></script>
</body>
</html>

 

 

코드 : app.js

변경사항

  • 약간 코드를 수정해서 제목 아래 줄을 넣었습니다.
  • Input 박스나 색상 선택 시 Canvas 내의 내용을 즉시 반영하도록 이벤트 리스너를 조정했습니다.
document.addEventListener("DOMContentLoaded", () => {
    const canvas = document.getElementById("thumbnailCanvas");
    const ctx = canvas.getContext("2d");

    const titleInput = document.getElementById("title");
    const subtitleInput = document.getElementById("subtitle");
    const categoryInput = document.getElementById("category");

    const titleColorPicker = document.getElementById("titleColor");
    const subtitleColorPicker = document.getElementById("subtitleColor");
    const categoryColorPicker = document.getElementById("categoryColor");

    const generateBtn = document.getElementById("generate");
    const saveBtn = document.getElementById("save");
    const dragDropArea = document.getElementById("dragDropArea");

    let backgroundImage = null;  // 현재 배경 이미지 저장용 변수
    let backgroundGradient = null; // 현재 배경 그라디언트 저장용 변수

    const textStyles = {
        title: { fontSize: 50, yPosPercent: 50 },
        subtitle: { fontSize: 28, yPosPercent: 35 },
        category: { fontSize: 20, yPosPercent: 10 }
    };

    // 랜덤 그라디언트 배경 생성
    function generateRandomGradient() {
        const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
        gradient.addColorStop(0, `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`);
        gradient.addColorStop(1, `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`);
        backgroundGradient = gradient;
        drawText(); // 그라디언트를 그리고 텍스트를 그리기
    }

    // 텍스트 그리기
    function drawText() {
        const title = titleInput.value;
        const subtitle = subtitleInput.value;
        const category = categoryInput.value;

        const titleColor = titleColorPicker.value;
        const subtitleColor = subtitleColorPicker.value;
        const categoryColor = categoryColorPicker.value;

        // 캔버스 지우기
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // 그라디언트 배경 그리기
        if (backgroundGradient) {
            ctx.fillStyle = backgroundGradient;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }
        // 제목 텍스트
        // ctx.fillStyle = titleColor;
        // ctx.textAlign = "center";
        // ctx.font = `bold ${textStyles.title.fontSize}px Arial`;
        // ctx.fillText(title, canvas.width / 2, canvas.height - (canvas.height * textStyles.title.yPosPercent) / 100);
        ctx.fillStyle = titleColor;
        ctx.textAlign = 'center';
        ctx.font = `bold ${textStyles.title.fontSize}px Arial`;
        const titleYPos = canvas.height - (canvas.height * textStyles.title.yPosPercent) / 100;
        ctx.fillText(title, canvas.width / 2, titleYPos);

        // 제목 아래 줄 그리기
        if (title) {
            const textWidth = ctx.measureText(title).width;
            const lineYPos = titleYPos + 10;  // 제목 글자와의 간격 10px
            ctx.strokeStyle = titleColor;
            ctx.lineWidth = 3;
            ctx.beginPath();
            ctx.moveTo((canvas.width - textWidth) / 2, lineYPos);
            ctx.lineTo((canvas.width + textWidth) / 2, lineYPos);
            ctx.stroke();
        }

        // 세부제목 텍스트
        ctx.fillStyle = subtitleColor;
        ctx.font = `bold ${textStyles.subtitle.fontSize}px Arial`;
        ctx.fillText(subtitle, canvas.width / 2, canvas.height - (canvas.height * textStyles.subtitle.yPosPercent) / 100);

        // 분류 텍스트
        ctx.fillStyle = categoryColor;
        ctx.font = `bold ${textStyles.category.fontSize}px Arial`;
        ctx.fillText(category, canvas.width / 2, canvas.height - (canvas.height * textStyles.category.yPosPercent) / 100);
    }

    // 모든 텍스트 입력 시 즉시 반영되도록 이벤트 리스너 추가
    titleInput.addEventListener('input', drawText);
    subtitleInput.addEventListener('input', drawText);
    categoryInput.addEventListener('input', drawText);
    titleColorPicker.addEventListener('input', drawText);
    subtitleColorPicker.addEventListener('input', drawText);
    categoryColorPicker.addEventListener('input', drawText);

    // 이미지 파일을 드래그 앤 드롭하여 캔버스에 배경으로 설정
    dragDropArea.addEventListener('dragover', (event) => {
        event.preventDefault();  // 기본 동작 방지
        dragDropArea.classList.add('dragging');
    });

    dragDropArea.addEventListener('dragleave', () => {
        dragDropArea.classList.remove('dragging');
    });

    dragDropArea.addEventListener('drop', (event) => {
        event.preventDefault();  // 기본 동작 방지
        event.stopPropagation();  // 브라우저의 기본 동작을 차단하여 탭에서 열리지 않도록 함
        dragDropArea.classList.remove('dragging');

        const file = event.dataTransfer.files[0];
        if (file && file.type.startsWith('image/')) {
            const img = new Image();
            const reader = new FileReader();

            reader.onload = (e) => {
                img.src = e.target.result;
                img.onload = () => {
                    backgroundImage = img;  // 이미지 배경 저장
                    backgroundGradient = null; // 그라디언트를 제거
                    drawText();  // 배경 이미지를 그리고 텍스트 다시 그리기
                };
            };

            reader.readAsDataURL(file);
        }
    });

    generateBtn.addEventListener("click", generateRandomGradient);

    saveBtn.addEventListener("click", () => {
        const link = document.createElement("a");
        link.download = "thumbnail.png";
        link.href = canvas.toDataURL();
        link.click();
    });

    // 페이지 로드 시 기본 그라디언트 배경 생성
    generateRandomGradient();

    // 초기 텍스트 그리기
    drawText();

});

 

 

코드 : style.css

body {
    font-family: Arial, sans-serif;
    background-color: #f0f0f0;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    margin: 0;
}

.container {
    display: flex;
    flex-direction: column;
    align-items: center;
}

h1 {
    margin-bottom: 20px;
}

.thumbnail-preview {
    margin-bottom: 20px;
}

canvas {
    border: 2px solid #ccc;
}

.controls {
    display: flex;
    flex-direction: column;
    gap: 15px;
    margin-bottom: 20px;
    width: 100%; /* 전체 너비 */
    max-width: 600px; /* 최대 너비 제한 */
    align-items: flex-start; /* 텍스트 박스와 버튼을 왼쪽 정렬 */
}

.input-group {
    display: flex;
    align-items: center;
    gap: 10px; /* 각 요소 간의 간격 추가 */
    width: 100%; /* 한 줄에 꽉 차도록 설정 */
}

label {
    font-size: 16px;
    font-weight: bold;
    width: 100px; /* 레이블의 고정 너비 설정 */
}

input[type="text"] {
    padding: 10px;
    font-size: 16px;
    width: 100%; /* 입력 필드가 가능한 넓게 확장되도록 설정 */
    flex: 1;
    border: 1px solid #ccc;
    border-radius: 5px;
}

input[type="color"] {
    width: 60px;
    height: 40px;
    border: none;
    cursor: pointer;
    flex-shrink: 0; /* 색상 선택기의 크기를 고정 */
}

button {
    padding: 10px 20px;
    font-size: 16px;
    border: none;
    background-color: #007bff;
    color: white;
    border-radius: 5px;
    cursor: pointer;
    width: 100%;
    max-width: 320px;
    text-align: center;
}

button:hover {
    background-color: #0056b3;
}

.drag-drop-area {
    margin-top: 20px;
    padding: 20px;
    border: 2px dashed #007bff;
    width: 100%;
    max-width: 600px;
    text-align: center;
    color: #007bff;
    background-color: #f9f9f9;
    border-radius: 10px;
}

.drag-drop-area p {
    margin: 0;
    font-size: 16px;
}

.drag-drop-area.dragging {
    background-color: #cce7ff;
    border-color: #0056b3;
}

 

결과 확인

최종적으로 만들어진 화면은 아래와 같습니다.

생각보다 깔끔하게 잘 나왔습니다.

사실 이 정도 나오기까지 많은 시행착오가 있었습니다. -_-;

 

 

 

제공되는 기능은 다음과 같습니다.

  • 제목, 세부제목, 분류 텍스트 별 색상을 별도로 설정할 수 있습니다.
  • 랜덤 그라디언트는 세로, 가로 가리지 않고 막~ 만듭니다... -_-;
  • 배경 이미지는 끌어다 놓으면 반영됩니다.

 

Unsplash에서 이미지를 다운받아 배치해 보면 다음과 같이 됩니다.

사진 : 배경사진을 반영한 결과물 (사진: Unsplash 의 Rodion Kutsaiev)

 

 

이번 글은 Thumbnail Maker 만들어보기를 해 봤습니다.

앞으로 블로그 글 쓸 때 이것을 많이 활용해 볼 생각입니다. ^^