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
@@ -1,6 +1,9 @@
package com.swyp.picke.domain.admin.controller;

import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest;
import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse;
import com.swyp.picke.domain.battle.thumbnail.BattleThumbnailCandidateService;
import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseRequest;
import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse;
Expand Down Expand Up @@ -32,6 +35,7 @@
public class AdminBattleController {

private final AdminBattleService adminBattleService;
private final BattleThumbnailCandidateService battleThumbnailCandidateService;

@Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)")
@PostMapping("/parse")
Expand All @@ -41,6 +45,14 @@ public ApiResponse<AdminBattleParseResponse> parseBattleScript(
return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText()));
}

@Operation(summary = "배틀 썸네일 AI 후보 생성 (S3 업로드만, 배틀에는 저장 안 함)")
@PostMapping("/thumbnail-candidates")
public ApiResponse<AdminBattleThumbnailCandidatesResponse> generateThumbnailCandidates(
@RequestBody @Valid AdminBattleThumbnailCandidateRequest request
) {
return ApiResponse.onSuccess(battleThumbnailCandidateService.generate(request));
}

@Operation(summary = "배틀 생성")
@PostMapping
public ApiResponse<AdminBattleDetailResponse> createBattle(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.swyp.picke.domain.admin.dto.battle.request;

import jakarta.validation.constraints.NotBlank;

/** 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. */
public record AdminBattleThumbnailCandidateRequest(
@NotBlank String title,
String summary,
String description,
Side optionA,
Side optionB
) {
public record Side(String title, String stance) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.swyp.picke.domain.admin.dto.battle.response;

import java.util.List;

/**
* 썸네일 후보 목록.
* key 는 배틀 저장 시 thumbnailUrl 로 그대로 넘기고, previewUrl 은 화면 미리보기용(presigned)이다.
*/
public record AdminBattleThumbnailCandidatesResponse(List<Candidate> candidates) {

public record Candidate(String key, String previewUrl) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.swyp.picke.domain.battle.thumbnail;

import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest;
import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest.Side;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse;
import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse.Candidate;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import com.swyp.picke.global.infra.s3.enums.FileCategory;
import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService;
import com.swyp.picke.global.infra.s3.service.S3UploadService;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* 배틀 내용 + 공통 스타일로 썸네일 후보를 여러 장 만들어 S3 에 올린다.
* 후보는 동시에 생성하고, 일부가 실패해도 성공한 것만 돌려준다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BattleThumbnailCandidateService {

static final int CANDIDATE_COUNT = 3;

private final BattleThumbnailStyleSource styleSource;
private final OpenAiImageClient imageClient;
private final S3UploadService s3UploadService;
private final S3PresignedUrlService s3PresignedUrlService;

public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) {
String prompt = buildPrompt(styleSource.promptTemplate(), request);
List<ReferenceImage> references = styleSource.referenceImages();

List<Candidate> candidates;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<Candidate>> futures = IntStream.range(0, CANDIDATE_COUNT)
.mapToObj(i -> CompletableFuture.supplyAsync(() -> generateOne(prompt, references), executor))
.toList();
candidates = futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList();
}

if (candidates.isEmpty()) {
throw new CustomException(ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED);
}
return new AdminBattleThumbnailCandidatesResponse(candidates);
}

private Candidate generateOne(String prompt, List<ReferenceImage> references) {
try {
byte[] image = imageClient.generate(prompt, references);
String key = upload(image);
return new Candidate(key, s3PresignedUrlService.generatePresignedUrl(key));
} catch (Exception e) {
log.warn("[Thumbnail] 썸네일 후보 1장 생성 실패", e);
return null;
}
}

private String upload(byte[] image) throws IOException {
File temp = Files.createTempFile("battle-thumbnail-", ".png").toFile();
try {
Files.write(temp.toPath(), image);
String key = FileCategory.BATTLE.getPath() + "/ai-" + UUID.randomUUID() + ".png";
return s3UploadService.uploadFile(key, temp);
} finally {
Files.deleteIfExists(temp.toPath());
}
}

static String buildPrompt(String template, AdminBattleThumbnailCandidateRequest request) {
return template
.replace("{title}", orEmpty(request.title()))
.replace("{summary}", orEmpty(request.summary()))
.replace("{description}", orEmpty(request.description()))
.replace("{optionA}", describe(request.optionA()))
.replace("{optionB}", describe(request.optionB()));
}

private static String describe(Side side) {
if (side == null) {
return "";
}
String title = orEmpty(side.title());
String stance = orEmpty(side.stance());
if (stance.isEmpty()) {
return title;
}
return title.isEmpty() ? stance : title + " - " + stance;
}

private static String orEmpty(String value) {
return value == null ? "" : value.trim();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.swyp.picke.domain.battle.thumbnail;

import java.util.List;

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

/**
* 프롬프트 템플릿. 다음 자리표시자를 배틀 내용으로 치환한다:
* {title} {summary} {description} {optionA} {optionB}
*/
String promptTemplate();

List<ReferenceImage> referenceImages();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.swyp.picke.domain.battle.thumbnail;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;

/**
* resources/battle-thumbnail/ 아래 파일로 스타일을 읽는다.
* - prompt.txt : 프롬프트 템플릿
* - references/ : 예시 썸네일(png/jpg/webp). 파일명 순서대로 image 1, image 2 ... 로 전달된다.
*/
@Slf4j
@Component
public class ClasspathBattleThumbnailStyleSource implements BattleThumbnailStyleSource {

private static final String PROMPT_PATH = "battle-thumbnail/prompt.txt";
private static final String REFERENCES_PATTERN = "classpath*:battle-thumbnail/references/*";
// OpenAI edits 는 레퍼런스 이미지를 최대 16장까지 받는다.
private static final int MAX_REFERENCES = 16;

private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();

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

@Override
public List<ReferenceImage> referenceImages() {
try {
Resource[] resources = resolver.getResources(REFERENCES_PATTERN);
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)));
return images;
} catch (IOException e) {
log.warn("[Thumbnail] 예시 이미지 목록 조회 실패 - 레퍼런스 없이 생성", e);
return List.of();
}
}

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

private static MediaType mediaTypeOf(String fileName) {
String lower = fileName.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return MediaType.IMAGE_PNG;
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG;
if (lower.endsWith(".webp")) return MediaType.parseMediaType("image/webp");
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.swyp.picke.domain.battle.thumbnail;

import java.util.Base64;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

/**
* OpenAI Images API 로 이미지 한 장을 생성한다.
* 레퍼런스 이미지가 있으면 edits(이미지 + 프롬프트), 없으면 generations(프롬프트만)를 호출한다.
*/
@Slf4j
@Component
public class OpenAiImageClient {

private static final int CONNECT_TIMEOUT_MS = 5000;
// 이미지 생성은 수십 초가 걸릴 수 있어 넉넉히 둔다.
private static final int READ_TIMEOUT_MS = 180000;

@Value("${openai.api-key}")
private String apiKey;

@Value("${openai.image.generations-url}")
private String generationsUrl;

@Value("${openai.image.edits-url}")
private String editsUrl;

@Value("${openai.image.model}")
private String model;

@Value("${openai.image.quality}")
private String quality;

@Value("${openai.image.size}")
private String size;

public byte[] generate(String prompt, List<ReferenceImage> references) {
Map<?, ?> response = references.isEmpty()
? callGenerations(prompt)
: callEdits(prompt, references);
return decodeFirstImage(response);
}

private Map<?, ?> callGenerations(String prompt) {
Map<String, Object> body = Map.of(
"model", model,
"prompt", prompt,
"n", 1,
"size", size,
"quality", quality);

return restClient().post()
.uri(generationsUrl)
.header("Authorization", "Bearer " + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
}

private Map<?, ?> callEdits(String prompt, List<ReferenceImage> references) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("model", model);
builder.part("prompt", prompt);
builder.part("n", "1");
builder.part("size", size);
builder.part("quality", quality);
for (ReferenceImage reference : references) {
builder.part("image[]", new NamedByteArrayResource(reference.bytes(), reference.fileName()))
.contentType(reference.mediaType());
}

return restClient().post()
.uri(editsUrl)
.header("Authorization", "Bearer " + apiKey)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(builder.build())
.retrieve()
.body(Map.class);
}

private byte[] decodeFirstImage(Map<?, ?> response) {
if (response == null || !(response.get("data") instanceof List<?> data) || data.isEmpty()) {
throw new IllegalStateException("OpenAI 이미지 응답에 data 가 없습니다.");
}
Object b64 = ((Map<?, ?>) data.get(0)).get("b64_json");
if (!(b64 instanceof String encoded) || encoded.isBlank()) {
throw new IllegalStateException("OpenAI 이미지 응답에 b64_json 이 없습니다.");
}
return Base64.getDecoder().decode(encoded);
}

private RestClient restClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
return RestClient.builder().requestFactory(factory).build();
}

/** multipart 파트로 보낼 때 파일명이 있어야 OpenAI 가 파일로 인식한다. */
private static class NamedByteArrayResource extends ByteArrayResource {
private final String fileName;

NamedByteArrayResource(byte[] bytes, String fileName) {
super(bytes);
this.fileName = fileName;
}

@Override
public String getFilename() {
return fileName;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.swyp.picke.domain.battle.thumbnail;

import org.springframework.http.MediaType;

/** 썸네일 스타일 레퍼런스로 함께 보내는 예시 이미지. */
public record ReferenceImage(String fileName, byte[] bytes, MediaType mediaType) {}
Loading
Loading