diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java index 895e227..951998b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java @@ -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; @@ -43,9 +51,10 @@ public class BattleThumbnailCandidateService { public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) { String template = styleSource.promptTemplate(); + String palette = styleSource.palette(); List references = styleSource.referenceImages(); - List prompts = scenesFor(request).stream() - .map(scene -> buildPrompt(template, scene)) + List prompts = scenesFor(request, references).stream() + .map(scene -> buildPrompt(template, palette, references, scene)) .toList(); AtomicInteger blockedCount = new AtomicInteger(); @@ -70,8 +79,8 @@ public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandi } /** 후보 수만큼 장면을 만든다. 장면 묘사에 실패하면 배틀 원문을 그대로 쓴다. */ - private List scenesFor(AdminBattleThumbnailCandidateRequest request) { - List scenes = sceneWriter.write(request, CANDIDATE_COUNT); + private List scenesFor(AdminBattleThumbnailCandidateRequest request, List references) { + List scenes = sceneWriter.write(request, CANDIDATE_COUNT, references); if (scenes.isEmpty()) { scenes = List.of(ThumbnailSceneWriter.describeBattle(request)); } @@ -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 references, String scene) { + return template + .replace("{palette}", palette) + .replace("{references}", referenceGuide(references)) + .replace("{scene}", scene); + } + + /** 첨부 순서대로 번호를 매겨, 설명이 있는 예시는 주제와 장면을 함께 알려준다. */ + private static String referenceGuide(List 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(); } } diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java index 9000533..f3c51fb 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java @@ -3,15 +3,19 @@ import java.util.List; /** - * 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 예시 이미지) 제공처. + * 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 색상 팔레트 + 예시 이미지) 제공처. * 지금은 리소스 파일 구현만 있고, 어드민에서 수정하게 되면 DB 구현으로 교체한다. */ public interface BattleThumbnailStyleSource { /** - * 프롬프트 템플릿. {scene} 자리에 배틀 내용으로 만든 장면 묘사가 들어간다. + * 프롬프트 템플릿. {palette} 에 색상 팔레트, {references} 에 예시 이미지 안내, + * {scene} 에 배틀 내용으로 만든 장면 묘사가 들어간다. */ String promptTemplate(); + /** 사용할 색상 목록. 헥사 코드와 용도를 한 줄씩 적은 텍스트. */ + String palette(); + List referenceImages(); } diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java index 8db1932..2c34b0c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java @@ -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; @@ -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; @@ -33,10 +38,19 @@ 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); } } @@ -44,12 +58,15 @@ public String promptTemplate() { public List referenceImages() { try { Resource[] resources = resolver.getResources(REFERENCES_PATTERN); + Map 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 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); @@ -57,15 +74,29 @@ public List referenceImages() { } } - 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; diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java new file mode 100644 index 0000000..21b4152 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java @@ -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(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java index bc51366..595e8e2 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java @@ -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) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java index 235e11a..5d22d94 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java @@ -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; @@ -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}") @@ -50,9 +60,10 @@ public class ThumbnailSceneWriter { private String model; /** 실패하면 빈 목록을 돌려준다. 호출부는 배틀 원문으로 대체한다. */ - public List write(AdminBattleThumbnailCandidateRequest request, int count) { + public List write( + AdminBattleThumbnailCandidateRequest request, int count, List references) { try { - JsonNode root = call(SYSTEM.formatted(count), describeBattle(request)); + JsonNode root = call(SYSTEM.formatted(count) + examples(references), describeBattle(request)); List scenes = new ArrayList<>(); for (JsonNode scene : root.path("scenes")) { String text = scene.asText("").trim(); @@ -67,6 +78,16 @@ public List write(AdminBattleThumbnailCandidateRequest request, int coun } } + /** 주제와 장면 설명이 모두 있는 예시만 "제목 → 장면" 예시로 쓴다. */ + static String examples(List 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()) diff --git a/src/main/resources/battle-thumbnail/palette.txt b/src/main/resources/battle-thumbnail/palette.txt new file mode 100644 index 0000000..5d3b108 --- /dev/null +++ b/src/main/resources/battle-thumbnail/palette.txt @@ -0,0 +1,7 @@ +- #E6CBAB 라이트 샌드: 빛을 받은 벽, 가장 넓은 밝은 면 +- #D4B391 웜 베이지: 바닥, 밝은 면의 그늘진 톤 +- #967357 카멜 브라운: 그림자 진 벽면, 넓은 중간 톤 +- #775A45 미디엄 브라운: 소품, 받침대 같은 사물 +- #654E3F 다크 토프: 바닥에 드리운 그림자 +- #291F19 에스프레소 블랙: 인물 실루엣, 가장 어두운 면 +- #F0D9BB 크림 하이라이트: 가장 밝은 빛, 하이라이트 diff --git a/src/main/resources/battle-thumbnail/prompt.txt b/src/main/resources/battle-thumbnail/prompt.txt index d4f43ea..d149bb4 100644 --- a/src/main/resources/battle-thumbnail/prompt.txt +++ b/src/main/resources/battle-thumbnail/prompt.txt @@ -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} diff --git a/src/main/resources/battle-thumbnail/references/image1.png b/src/main/resources/battle-thumbnail/references/art1.png similarity index 100% rename from src/main/resources/battle-thumbnail/references/image1.png rename to src/main/resources/battle-thumbnail/references/art1.png diff --git a/src/main/resources/battle-thumbnail/references/art1.txt b/src/main/resources/battle-thumbnail/references/art1.txt new file mode 100644 index 0000000..778c1ec --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art1.txt @@ -0,0 +1,2 @@ +주제: AI가 만든 작품, 예술인가 모방인가? +장면: 빈 공간 한가운데 상자에 걸터앉은 로봇이 이젤 위 캔버스에 붓으로 풍경화를 그리고 있다. 로봇과 이젤의 그림자가 바닥에 길게 드리운다. diff --git a/src/main/resources/battle-thumbnail/references/image2.png b/src/main/resources/battle-thumbnail/references/art2.png similarity index 100% rename from src/main/resources/battle-thumbnail/references/image2.png rename to src/main/resources/battle-thumbnail/references/art2.png diff --git a/src/main/resources/battle-thumbnail/references/art2.txt b/src/main/resources/battle-thumbnail/references/art2.txt new file mode 100644 index 0000000..8a4dc13 --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art2.txt @@ -0,0 +1,2 @@ +주제: 라이언 일병 구하러 8명이 목숨 걸어도 될까? +장면: 지평선 위로 커다란 해가 떠 있고, 그 해를 등진 병사들의 실루엣이 나란히 서 있다. 멀리 폐허가 된 건물의 윤곽이 낮게 깔려 있다. diff --git a/src/main/resources/battle-thumbnail/references/image3.png b/src/main/resources/battle-thumbnail/references/art3.png similarity index 100% rename from src/main/resources/battle-thumbnail/references/image3.png rename to src/main/resources/battle-thumbnail/references/art3.png diff --git a/src/main/resources/battle-thumbnail/references/art3.txt b/src/main/resources/battle-thumbnail/references/art3.txt new file mode 100644 index 0000000..98b809e --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art3.txt @@ -0,0 +1,2 @@ +주제: 클래식은 대중음악보다 고상한가? +장면: 사선으로 떨어지는 조명 한가운데 단상 위에 선 지휘자의 뒷모습 실루엣이 지휘봉을 들어 올리고 있다. 양옆에는 악기를 든 연주자들이 어두운 실루엣으로 앉아 있다. diff --git a/src/main/resources/battle-thumbnail/references/art4.png b/src/main/resources/battle-thumbnail/references/art4.png new file mode 100644 index 0000000..979a465 Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/art4.png differ diff --git a/src/main/resources/battle-thumbnail/references/art4.txt b/src/main/resources/battle-thumbnail/references/art4.txt new file mode 100644 index 0000000..c845dfa --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art4.txt @@ -0,0 +1,2 @@ +주제: 이해 못 한 예술은 실패작일까? +장면: 텅 빈 전시실 벽에 조명을 받는 액자 하나가 걸려 있고, 그 안에는 붓으로 그린 물음표만 있다. 정장 차림의 남자가 뒷짐을 지고 서서 그것을 올려다보며 긴 그림자를 드리운다. diff --git a/src/main/resources/battle-thumbnail/references/art5.png b/src/main/resources/battle-thumbnail/references/art5.png new file mode 100644 index 0000000..68f572a Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/art5.png differ diff --git a/src/main/resources/battle-thumbnail/references/art5.txt b/src/main/resources/battle-thumbnail/references/art5.txt new file mode 100644 index 0000000..15f9c31 --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art5.txt @@ -0,0 +1,2 @@ +주제: 예술은 세상을 바꾸기 위해 존재하는가? +장면: 지구를 마주 보고 선 작은 사람의 실루엣을 중심으로, 춤과 그림, 무대, 연주, 책의 장면이 담긴 네모난 면들이 하나의 입체 조각처럼 맞물려 떠 있다. 주변은 넓게 비어 있다. diff --git a/src/main/resources/battle-thumbnail/references/art6.png b/src/main/resources/battle-thumbnail/references/art6.png new file mode 100644 index 0000000..506a01c Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/art6.png differ diff --git a/src/main/resources/battle-thumbnail/references/art6.txt b/src/main/resources/battle-thumbnail/references/art6.txt new file mode 100644 index 0000000..c6ce07f --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art6.txt @@ -0,0 +1,2 @@ +주제: 예술은 반드시 아름다워야 하는가? +장면: 빈 벽에 일그러진 얼굴이 거친 붓질로 그려진 큰 그림이 걸려 있다. 정장 차림의 남자가 그 앞에 서서 턱을 괴고 그림을 바라보며 긴 그림자를 드리운다. diff --git a/src/main/resources/battle-thumbnail/references/art7.png b/src/main/resources/battle-thumbnail/references/art7.png new file mode 100644 index 0000000..cf9f16f Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/art7.png differ diff --git a/src/main/resources/battle-thumbnail/references/art7.txt b/src/main/resources/battle-thumbnail/references/art7.txt new file mode 100644 index 0000000..7cff712 --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art7.txt @@ -0,0 +1,2 @@ +주제: 뒤샹의 변기, 예술인가 도발인가? +장면: 전시대 위에 흰 변기가 작품처럼 놓여 있고, 그 옆 상자에 걸터앉아 생각에 잠긴 남자의 옆모습 실루엣이 있다. 사선으로 드는 빛이 벽과 바닥을 밝은 면과 어두운 면으로 나눈다. diff --git a/src/main/resources/battle-thumbnail/references/art8.png b/src/main/resources/battle-thumbnail/references/art8.png new file mode 100644 index 0000000..a879459 Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/art8.png differ diff --git a/src/main/resources/battle-thumbnail/references/art8.txt b/src/main/resources/battle-thumbnail/references/art8.txt new file mode 100644 index 0000000..00cf21c --- /dev/null +++ b/src/main/resources/battle-thumbnail/references/art8.txt @@ -0,0 +1,2 @@ +주제: 수억 원의 바나나, 거품일까 가치 있을까? +장면: 조명이 비추는 흰 벽 한가운데 바나나 하나가 테이프로 붙어 있고, 거기에 빈 가격표가 매달려 있다. 양옆 가장자리에서 관람객들의 어두운 실루엣이 그것을 지켜본다. diff --git a/src/main/resources/battle-thumbnail/references/palette.jpg b/src/main/resources/battle-thumbnail/references/palette.jpg deleted file mode 100644 index 508cb08..0000000 Binary files a/src/main/resources/battle-thumbnail/references/palette.jpg and /dev/null differ diff --git a/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java index f3bbcae..963ab53 100644 --- a/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; @ExtendWith(MockitoExtension.class) class BattleThumbnailCandidateServiceTest { @@ -53,8 +54,9 @@ void setUp() { service = new BattleThumbnailCandidateService( styleSource, sceneWriter, imageClient, s3UploadService, s3PresignedUrlService); lenient().when(styleSource.promptTemplate()).thenReturn(TEMPLATE); + lenient().when(styleSource.palette()).thenReturn("PALETTE"); lenient().when(styleSource.referenceImages()).thenReturn(List.of()); - lenient().when(sceneWriter.write(any(), anyInt())).thenReturn(List.of("장면1", "장면2", "장면3")); + lenient().when(sceneWriter.write(any(), anyInt(), anyList())).thenReturn(List.of("장면1", "장면2", "장면3")); } private void stubUploadSuccess() { @@ -84,7 +86,7 @@ private void stubUploadSuccess() { @Test void 장면_묘사에_실패하면_제목_기반_원문으로_생성한다() { - when(sceneWriter.write(any(), anyInt())).thenReturn(List.of()); + when(sceneWriter.write(any(), anyInt(), anyList())).thenReturn(List.of()); when(imageClient.generate(anyString(), anyList())).thenReturn(new byte[]{1}); stubUploadSuccess(); @@ -95,6 +97,51 @@ private void stubUploadSuccess() { assertThat(fallback).contains("트롤리 딜레마").doesNotContain("A 입장", "B 입장"); } + @Test + void 프롬프트에_팔레트와_예시_이미지_장수를_채운다() { + String template = "{palette} / {references} / {scene}"; + List references = List.of(reference(ReferenceCaption.NONE), reference(ReferenceCaption.NONE)); + + String withReferences = BattleThumbnailCandidateService.buildPrompt(template, "#492C24", references, "장면"); + String withoutReferences = BattleThumbnailCandidateService.buildPrompt(template, "#492C24", List.of(), "장면"); + + assertThat(withReferences).startsWith("#492C24 / ").endsWith(" / 장면").contains("이미지 2장"); + assertThat(withReferences).doesNotContain("번째 예시"); + assertThat(withoutReferences).contains("예시 이미지는 없습니다").doesNotContain("0장"); + assertThat(withReferences + withoutReferences).doesNotContain("{"); + } + + @Test + void 설명이_있는_예시는_첨부_순서_번호와_함께_주제와_장면을_알려준다() { + List references = List.of( + reference(ReferenceCaption.NONE), + reference(new ReferenceCaption("AI 예술", "이젤 앞의 로봇")), + reference(new ReferenceCaption("전쟁", ""))); + + String prompt = BattleThumbnailCandidateService.buildPrompt("{references}", "", references, ""); + + assertThat(prompt) + .contains("2번째 예시: 주제 \"AI 예술\" → 장면 \"이젤 앞의 로봇\"") + .contains("3번째 예시: 주제 \"전쟁\"") + .doesNotContain("1번째 예시", "\"전쟁\" →"); + } + + @Test + void 장면_기획에는_주제와_장면이_모두_있는_예시만_넘긴다() { + List references = List.of( + reference(new ReferenceCaption("AI 예술", "이젤 앞의 로봇")), + reference(new ReferenceCaption("전쟁", ""))); + + assertThat(ThumbnailSceneWriter.examples(references)) + .contains("- 제목: AI 예술 → 장면: 이젤 앞의 로봇") + .doesNotContain("전쟁"); + assertThat(ThumbnailSceneWriter.examples(List.of(reference(ReferenceCaption.NONE)))).isEmpty(); + } + + private static ReferenceImage reference(ReferenceCaption caption) { + return new ReferenceImage("image.png", new byte[]{1}, MediaType.IMAGE_PNG, caption); + } + @Test void 일부_실패하면_성공한_후보만_돌려준다() { when(imageClient.generate(eq("STYLE / 장면1"), anyList())).thenThrow(new IllegalStateException("boom")); diff --git a/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java b/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java index d4a301e..043f2f1 100644 --- a/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java @@ -4,26 +4,41 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; class ClasspathBattleThumbnailStyleSourceTest { private final ClasspathBattleThumbnailStyleSource source = new ClasspathBattleThumbnailStyleSource(); @Test - void 프롬프트_템플릿에_장면_자리표시자가_있다() { + void 프롬프트_템플릿에_자리표시자가_있다() { assertThat(source.promptTemplate()) - .contains("{scene}"); + .contains("{palette}", "{references}", "{scene}"); } @Test - void 예시_이미지와_팔레트를_파일명_순서대로_읽는다() { + void 팔레트를_헥사_코드로_읽는다() { + assertThat(source.palette()).containsPattern("#[0-9A-Fa-f]{6}"); + } + + @Test + void 예시_이미지를_파일명_순서대로_읽는다() { List images = source.referenceImages(); - assertThat(images).extracting(ReferenceImage::fileName) - .containsExactly("image1.png", "image2.png", "image3.png", "palette.jpg"); - assertThat(images).extracting(ReferenceImage::mediaType) - .containsExactly(MediaType.IMAGE_PNG, MediaType.IMAGE_PNG, MediaType.IMAGE_PNG, MediaType.IMAGE_JPEG); - assertThat(images).allSatisfy(image -> assertThat(image.bytes()).isNotEmpty()); + assertThat(images).isNotEmpty(); + assertThat(images).extracting(ReferenceImage::fileName).isSorted(); + assertThat(images).allSatisfy(image -> { + assertThat(image.bytes()).isNotEmpty(); + assertThat(image.mediaType().getType()).isEqualTo("image"); + }); + } + + @Test + void 예시_설명_파일에서_주제와_장면을_읽는다() { + ReferenceCaption caption = ReferenceCaption.parse("주제: AI가 그린 그림도 예술인가\n장면: 이젤 앞의 로봇 \n"); + + assertThat(caption.topic()).isEqualTo("AI가 그린 그림도 예술인가"); + assertThat(caption.scene()).isEqualTo("이젤 앞의 로봇"); + assertThat(caption.hasScene()).isTrue(); + assertThat(ReferenceCaption.parse("장면: 주제 없는 장면").hasScene()).isFalse(); } }