Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public class BattleThumbnailCandidateService {

static final int CANDIDATE_COUNT = 3;

private static final String REFERENCE_GUIDE = """
- 첨부한 이미지 %d장은 모두 스타일 예시입니다.
- 면 처리, 명암, 형태를 단순화하는 정도만 참고합니다.
- 예시의 소재(인물, 사물), 구도, 색상은 따라 하지 않습니다. 색상은 [색상]의 팔레트를, 구도는 [구도]를 따릅니다.""";
private static final String CAPTION_GUIDE =
"- 아래는 각 예시가 어떤 주제를 어떤 장면으로 옮긴 것인지에 대한 설명입니다. 주제를 상징으로 옮기는 방식만 참고합니다.";
private static final String NO_REFERENCE_GUIDE = "- 첨부한 예시 이미지는 없습니다. [스타일]의 설명만으로 그립니다.";

private final BattleThumbnailStyleSource styleSource;
private final ThumbnailSceneWriter sceneWriter;
private final OpenAiImageClient imageClient;
Expand All @@ -43,9 +51,10 @@ public class BattleThumbnailCandidateService {

public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) {
String template = styleSource.promptTemplate();
String palette = styleSource.palette();
List<ReferenceImage> references = styleSource.referenceImages();
List<String> prompts = scenesFor(request).stream()
.map(scene -> buildPrompt(template, scene))
List<String> prompts = scenesFor(request, references).stream()
.map(scene -> buildPrompt(template, palette, references, scene))
.toList();

AtomicInteger blockedCount = new AtomicInteger();
Expand All @@ -70,8 +79,8 @@ public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandi
}

/** 후보 수만큼 장면을 만든다. 장면 묘사에 실패하면 배틀 원문을 그대로 쓴다. */
private List<String> scenesFor(AdminBattleThumbnailCandidateRequest request) {
List<String> scenes = sceneWriter.write(request, CANDIDATE_COUNT);
private List<String> scenesFor(AdminBattleThumbnailCandidateRequest request, List<ReferenceImage> references) {
List<String> scenes = sceneWriter.write(request, CANDIDATE_COUNT, references);
if (scenes.isEmpty()) {
scenes = List.of(ThumbnailSceneWriter.describeBattle(request));
}
Expand Down Expand Up @@ -107,7 +116,32 @@ private String upload(byte[] image) throws IOException {
}
}

static String buildPrompt(String template, String scene) {
return template.replace("{scene}", scene);
static String buildPrompt(String template, String palette, List<ReferenceImage> references, String scene) {
return template
.replace("{palette}", palette)
.replace("{references}", referenceGuide(references))
.replace("{scene}", scene);
}

/** 첨부 순서대로 번호를 매겨, 설명이 있는 예시는 주제와 장면을 함께 알려준다. */
private static String referenceGuide(List<ReferenceImage> references) {
if (references.isEmpty()) {
return NO_REFERENCE_GUIDE;
}
StringBuilder guide = new StringBuilder(REFERENCE_GUIDE.formatted(references.size()));
if (references.stream().anyMatch(reference -> reference.caption().hasTopic())) {
guide.append('\n').append(CAPTION_GUIDE);
}
for (int i = 0; i < references.size(); i++) {
ReferenceCaption caption = references.get(i).caption();
if (!caption.hasTopic()) {
continue;
}
guide.append("\n - ").append(i + 1).append("번째 예시: 주제 \"").append(caption.topic()).append('"');
if (caption.hasScene()) {
guide.append(" → 장면 \"").append(caption.scene()).append('"');
}
}
return guide.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
import java.util.List;

/**
* 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 예시 이미지) 제공처.
* 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 색상 팔레트 + 예시 이미지) 제공처.
* 지금은 리소스 파일 구현만 있고, 어드민에서 수정하게 되면 DB 구현으로 교체한다.
*/
public interface BattleThumbnailStyleSource {

/**
* 프롬프트 템플릿. {scene} 자리에 배틀 내용으로 만든 장면 묘사가 들어간다.
* 프롬프트 템플릿. {palette} 에 색상 팔레트, {references} 에 예시 이미지 안내,
* {scene} 에 배틀 내용으로 만든 장면 묘사가 들어간다.
*/
String promptTemplate();

/** 사용할 색상 목록. 헥사 코드와 용도를 한 줄씩 적은 텍스트. */
String palette();

List<ReferenceImage> referenceImages();
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
Expand All @@ -18,13 +20,16 @@
/**
* resources/battle-thumbnail/ 아래 파일로 스타일을 읽는다.
* - prompt.txt : 프롬프트 템플릿
* - references/ : 예시 썸네일(png/jpg/webp). 파일명 순서대로 image 1, image 2 ... 로 전달된다.
* - palette.txt : 색상 팔레트(헥사 코드)
* - references/ : 스타일 예시 썸네일(png/jpg/webp). 파일명 순서대로 전달된다.
* 같은 이름의 txt(art1.png ↔ art1.txt)가 있으면 그 예시의 주제·장면 설명으로 읽는다.
*/
@Slf4j
@Component
public class ClasspathBattleThumbnailStyleSource implements BattleThumbnailStyleSource {

private static final String PROMPT_PATH = "battle-thumbnail/prompt.txt";
private static final String PALETTE_PATH = "battle-thumbnail/palette.txt";
private static final String REFERENCES_PATTERN = "classpath*:battle-thumbnail/references/*";
// OpenAI edits 는 레퍼런스 이미지를 최대 16장까지 받는다.
private static final int MAX_REFERENCES = 16;
Expand All @@ -33,39 +38,65 @@ public class ClasspathBattleThumbnailStyleSource implements BattleThumbnailStyle

@Override
public String promptTemplate() {
try (InputStream in = new ClassPathResource(PROMPT_PATH).getInputStream()) {
return readText(PROMPT_PATH);
}

@Override
public String palette() {
return readText(PALETTE_PATH).strip();
}

private String readText(String path) {
try (InputStream in = new ClassPathResource(path).getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("썸네일 프롬프트 파일을 읽을 수 없습니다: " + PROMPT_PATH, e);
throw new IllegalStateException("썸네일 스타일 파일을 읽을 수 없습니다: " + path, e);
}
}

@Override
public List<ReferenceImage> referenceImages() {
try {
Resource[] resources = resolver.getResources(REFERENCES_PATTERN);
Map<String, Resource> captions = Arrays.stream(resources)
.filter(r -> r.getFilename() != null && r.getFilename().toLowerCase(Locale.ROOT).endsWith(".txt"))
.collect(Collectors.toMap(r -> baseName(r.getFilename()), r -> r, (first, second) -> first));
List<ReferenceImage> images = new ArrayList<>();
Arrays.stream(resources)
.filter(r -> r.getFilename() != null && mediaTypeOf(r.getFilename()) != null)
.sorted(Comparator.comparing(Resource::getFilename))
.limit(MAX_REFERENCES)
.forEach(r -> images.add(read(r)));
.forEach(r -> images.add(read(r, captions.get(baseName(r.getFilename())))));
return images;
} catch (IOException e) {
log.warn("[Thumbnail] 예시 이미지 목록 조회 실패 - 레퍼런스 없이 생성", e);
return List.of();
}
}

private ReferenceImage read(Resource resource) {
private ReferenceImage read(Resource resource, Resource captionResource) {
try (InputStream in = resource.getInputStream()) {
String fileName = resource.getFilename();
return new ReferenceImage(fileName, in.readAllBytes(), mediaTypeOf(fileName));
return new ReferenceImage(fileName, in.readAllBytes(), mediaTypeOf(fileName), readCaption(captionResource));
} catch (IOException e) {
throw new IllegalStateException("예시 이미지를 읽을 수 없습니다: " + resource.getFilename(), e);
}
}

private ReferenceCaption readCaption(Resource resource) throws IOException {
if (resource == null) {
return ReferenceCaption.NONE;
}
try (InputStream in = resource.getInputStream()) {
return ReferenceCaption.parse(new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
}

private static String baseName(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot < 0 ? fileName : fileName.substring(0, dot);
}

private static MediaType mediaTypeOf(String fileName) {
String lower = fileName.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return MediaType.IMAGE_PNG;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.swyp.picke.domain.battle.thumbnail;

/**
* 예시 이미지가 어떤 배틀 주제를 어떤 장면으로 옮긴 것인지에 대한 설명.
* 예시 이미지와 같은 이름의 txt 파일에 "주제: ...", "장면: ..." 을 한 줄씩 적는다. 없으면 화풍 예시로만 쓰인다.
*/
public record ReferenceCaption(String topic, String scene) {

public static final ReferenceCaption NONE = new ReferenceCaption("", "");

private static final String TOPIC_PREFIX = "주제:";
private static final String SCENE_PREFIX = "장면:";

public static ReferenceCaption parse(String text) {
String topic = "";
String scene = "";
for (String line : text.split("\\R")) {
String trimmed = line.trim();
if (trimmed.startsWith(TOPIC_PREFIX)) {
topic = trimmed.substring(TOPIC_PREFIX.length()).trim();
} else if (trimmed.startsWith(SCENE_PREFIX)) {
scene = trimmed.substring(SCENE_PREFIX.length()).trim();
}
}
return new ReferenceCaption(topic, scene);
}

public boolean hasTopic() {
return !topic.isEmpty();
}

/** 주제와 장면이 모두 있어야 "주제 → 장면" 예시로 쓸 수 있다. */
public boolean hasScene() {
return hasTopic() && !scene.isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
import org.springframework.http.MediaType;

/** 썸네일 스타일 레퍼런스로 함께 보내는 예시 이미지. */
public record ReferenceImage(String fileName, byte[] bytes, MediaType mediaType) {}
public record ReferenceImage(String fileName, byte[] bytes, MediaType mediaType, ReferenceCaption caption) {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
Expand All @@ -32,12 +33,21 @@ public class ThumbnailSceneWriter {
- 찬반 두 입장을 나눠 그리거나, 화면을 좌우로 분할하거나, 두 대상이 맞서는 대립 구도로 만들지 않는다.
- 각 장면은 2~3문장의 한국어로, 화면에 무엇이 어디에 어떻게 보이는지 구체적으로 쓴다.
- 실루엣, 사물, 빛과 그림자, 공간, 자연물 같은 은유로 표현한다.
- 장면은 미니멀한 플랫 그래픽 포스터로 그려진다. 실루엣만으로 알아볼 수 있는 크고 단순한 형태로 구성한다.
- 중심 피사체는 하나로 잡아 화면 중앙에 모으고, 보조 요소는 한두 개까지만 둔다.
- 여러 인물이나 군중이 필요하면 하나의 덩어리로 읽히는 단순한 실루엣 무리로 둔다.
- 배경은 비워 두거나 단순한 면, 빛줄기, 지평선 정도로만 쓴다. 복잡한 풍경, 자잘한 소품은 넣지 않는다.
- 장면끼리는 중심 소재와 시점을 서로 다르게 잡는다.
- 부상, 피, 시신, 무기 사용, 자해, 죽어가는 사람 등 위해를 직접 묘사하지 않는다.
- 텍스트, 글자, 숫자, 로고는 장면에 넣지 않는다.
- 화풍이나 색상은 쓰지 않는다(별도로 지정된다).
반드시 JSON 만 출력: {"scenes":["...","..."]}
""";

private static final String EXAMPLES_HEADER = """
아래는 기존 썸네일이 주제를 장면으로 옮긴 예시다. 상징화하는 수준과 방식만 참고하고, 예시의 소재를 그대로 쓰지 않는다.
""";

private final ObjectMapper objectMapper = new ObjectMapper();

@Value("${openai.api-key}")
Expand All @@ -50,9 +60,10 @@ public class ThumbnailSceneWriter {
private String model;

/** 실패하면 빈 목록을 돌려준다. 호출부는 배틀 원문으로 대체한다. */
public List<String> write(AdminBattleThumbnailCandidateRequest request, int count) {
public List<String> write(
AdminBattleThumbnailCandidateRequest request, int count, List<ReferenceImage> references) {
try {
JsonNode root = call(SYSTEM.formatted(count), describeBattle(request));
JsonNode root = call(SYSTEM.formatted(count) + examples(references), describeBattle(request));
List<String> scenes = new ArrayList<>();
for (JsonNode scene : root.path("scenes")) {
String text = scene.asText("").trim();
Expand All @@ -67,6 +78,16 @@ public List<String> write(AdminBattleThumbnailCandidateRequest request, int coun
}
}

/** 주제와 장면 설명이 모두 있는 예시만 "제목 → 장면" 예시로 쓴다. */
static String examples(List<ReferenceImage> references) {
String lines = references.stream()
.map(ReferenceImage::caption)
.filter(ReferenceCaption::hasScene)
.map(caption -> "- 제목: " + caption.topic() + " → 장면: " + caption.scene())
.collect(Collectors.joining("\n"));
return lines.isEmpty() ? "" : EXAMPLES_HEADER + lines;
}

static String describeBattle(AdminBattleThumbnailCandidateRequest request) {
return "제목: " + orEmpty(request.title())
+ "\n한 줄 요약: " + orEmpty(request.summary())
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/battle-thumbnail/palette.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- #E6CBAB 라이트 샌드: 빛을 받은 벽, 가장 넓은 밝은 면
- #D4B391 웜 베이지: 바닥, 밝은 면의 그늘진 톤
- #967357 카멜 브라운: 그림자 진 벽면, 넓은 중간 톤
- #775A45 미디엄 브라운: 소품, 받침대 같은 사물
- #654E3F 다크 토프: 바닥에 드리운 그림자
- #291F19 에스프레소 블랙: 인물 실루엣, 가장 어두운 면
- #F0D9BB 크림 하이라이트: 가장 밝은 빛, 하이라이트
31 changes: 25 additions & 6 deletions src/main/resources/battle-thumbnail/prompt.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
미니멀한 플랫 그래픽 포스터 스타일의 일러스트. 강한 명암 대비와 실루엣 중심의 형태, 경계가 선명한 하드 섀도우를 활용해 영화 포스터처럼 상징적인 구도로 표현해주세요. 디테일은 최소화하고, 세련되고 트렌디한 에디토리얼 일러스트의 분위기를 살려주세요.
배틀 썸네일로 쓸 일러스트 한 장을 그려주세요. 아래 항목을 모두 지켜주세요.

캔버스는 1:1 정사각형으로 설정해주세요. 주요 피사체와 핵심 시각 요소는 프레임 중앙의 약 55% 영역(1080×1080 기준 600×600px) 안에 배치하고, 해당 영역을 충분히 채우도록 구성해주세요. 바깥쪽 상하좌우에는 각각 약 22%(1080 기준 240px)의 넓고 균형 잡힌 빈 여백을 확보하고, 장식 요소 없이 깔끔하게 유지해주세요.
[스타일]
- 미니멀한 플랫 그래픽 포스터 스타일의 에디토리얼 일러스트.
- 형태는 실루엣 중심의 크고 단순한 면으로 표현하고, 디테일과 질감은 최소화합니다.
- 강한 명암 대비와 경계가 선명한 하드 섀도우를 사용합니다. 부드러운 그라데이션, 사실적인 묘사, 3D 렌더링, 외곽선 위주의 만화풍은 쓰지 않습니다.
- 영화 포스터처럼 한눈에 읽히는 상징적인 구도로 구성합니다.

첨부한 1~3번째 이미지는 스타일 예시입니다. 화풍, 명암 처리, 구도를 참고하되 예시의 소재(인물, 사물)를 그대로 따라 그리지는 마세요.
마지막(4번째) 첨부 이미지는 색상 팔레트입니다. 색상과 전체 색감은 이 팔레트의 색만 사용해 맞춰주세요. 팔레트의 색상 칸 모양이나 숫자, 글자는 그리지 마세요.
텍스트, 글자, 로고, 워터마크는 포함하지 마세요.
[구도]
- 캔버스는 1:1 정사각형입니다.
- 주요 피사체와 핵심 시각 요소는 프레임 중앙의 약 55% 영역(1080×1080 기준 600×600px) 안에 배치하고, 그 영역을 충분히 채웁니다.
- 바깥쪽 상하좌우에는 각각 약 22%(1080 기준 240px)의 여백을 둡니다. 여백은 배경색 단색 면으로만 채우고 장식 요소를 넣지 않습니다.
- 중심 피사체는 하나로 분명하게 잡고, 주변 요소는 그것을 돕는 최소한만 둡니다.

아래 장면을 하나의 상징적인 일러스트로 그려주세요.
[색상]
- 아래 팔레트의 색만 사용합니다. 팔레트에 없는 색상 계열은 쓰지 않습니다.
- 한 장에는 팔레트 중 3~5색만 골라 쓰고, 명암은 같은 팔레트 안의 어두운 색과 밝은 색으로 표현합니다.
{palette}

[레퍼런스 이미지]
{references}

[금지]
- 텍스트, 글자, 숫자, 로고, 워터마크, 테두리(프레임)를 넣지 않습니다.
- 색상 견본 칸이나 팔레트 모양을 그림 안에 그리지 않습니다.

[장면]
아래 장면을 위 스타일로 하나의 상징적인 일러스트로 그려주세요.
{scene}
2 changes: 2 additions & 0 deletions src/main/resources/battle-thumbnail/references/art1.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
주제: AI가 만든 작품, 예술인가 모방인가?
장면: 빈 공간 한가운데 상자에 걸터앉은 로봇이 이젤 위 캔버스에 붓으로 풍경화를 그리고 있다. 로봇과 이젤의 그림자가 바닥에 길게 드리운다.
2 changes: 2 additions & 0 deletions src/main/resources/battle-thumbnail/references/art2.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
주제: 라이언 일병 구하러 8명이 목숨 걸어도 될까?
장면: 지평선 위로 커다란 해가 떠 있고, 그 해를 등진 병사들의 실루엣이 나란히 서 있다. 멀리 폐허가 된 건물의 윤곽이 낮게 깔려 있다.
2 changes: 2 additions & 0 deletions src/main/resources/battle-thumbnail/references/art3.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
주제: 클래식은 대중음악보다 고상한가?
장면: 사선으로 떨어지는 조명 한가운데 단상 위에 선 지휘자의 뒷모습 실루엣이 지휘봉을 들어 올리고 있다. 양옆에는 악기를 든 연주자들이 어두운 실루엣으로 앉아 있다.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/main/resources/battle-thumbnail/references/art4.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
주제: 이해 못 한 예술은 실패작일까?
장면: 텅 빈 전시실 벽에 조명을 받는 액자 하나가 걸려 있고, 그 안에는 붓으로 그린 물음표만 있다. 정장 차림의 남자가 뒷짐을 지고 서서 그것을 올려다보며 긴 그림자를 드리운다.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/main/resources/battle-thumbnail/references/art5.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
주제: 예술은 세상을 바꾸기 위해 존재하는가?
장면: 지구를 마주 보고 선 작은 사람의 실루엣을 중심으로, 춤과 그림, 무대, 연주, 책의 장면이 담긴 네모난 면들이 하나의 입체 조각처럼 맞물려 떠 있다. 주변은 넓게 비어 있다.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading