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 @@ -67,6 +67,8 @@ public enum ErrorStatus implements BaseErrorCode {
IMAGE_FANOUT_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "ADMIN4029", "캐릭터 이미지 fan-out job을 찾을 수 없습니다."),
IMAGE_FANOUT_RETRY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "ADMIN4030", "결과 게시를 재시도할 수 없는 캐릭터 이미지 fan-out job 상태입니다."),
IMAGE_FANOUT_RESULT_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "ADMIN4031", "재적용할 OpenAI Batch 결과 파일이 없습니다."),
IMAGE_REFERENCE_JOB_ACTIVE(HttpStatus.CONFLICT, "ADMIN4032", "해당 책의 대표 후보사진 생성 job이 이미 진행 중입니다."),
IMAGE_REFERENCE_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "ADMIN4033", "대표 후보사진 생성 job을 찾을 수 없습니다."),
IMAGE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ADMIN5020", "캐릭터 이미지 생성에 실패했습니다."),

// Bookmark
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/kw/readwith/config/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ public Executor normalizationJobExecutor() {
executor.initialize();
return executor;
}

@Bean(name = "referenceCandidateJobExecutor")
public Executor referenceCandidateJobExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("reference-job-");
executor.setAllowCoreThreadTimeOut(true);
executor.setKeepAliveSeconds(60);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}

@Bean(name = "referenceCandidateSlotExecutor")
public Executor referenceCandidateSlotExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(2);
executor.setThreadNamePrefix("reference-slot-");
executor.setAllowCoreThreadTimeOut(true);
executor.setKeepAliveSeconds(60);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}

@Override
public Executor getAsyncExecutor() {
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/kw/readwith/config/CharacterImageProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,15 @@ public class CharacterImageProperties {
* OpenAI Batch 상태를 다시 확인하는 간격입니다.
*/
private long batchPollIntervalMs = 30000;

/**
* 관리자 기준 이미지 후보 생성 개수입니다.
*/
private int referenceCandidateCount = 2;

/**
* 기준 이미지 후보 한 슬롯의 최대 처리 시간입니다.
*/
private long referenceCandidateTimeoutMs = 420000;
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
public enum ProcessingPipelineType {
NORMALIZATION,
AI_ANALYSIS,
IMAGE_REFERENCE_GENERATION,
IMAGE_GENERATION
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@Schema(
description = """
관리자 책 단위 이미지 생성 콘솔 상태 응답입니다.
대표 캐릭터 후보사진 4장과 캐릭터별 현재 이미지 상태를 한 번에 내려줍니다.
대표 캐릭터 후보사진과 캐릭터별 현재 이미지 상태를 한 번에 내려줍니다.
관리자 페이지는 이 응답의 status와 nextAction만 보고 다음 버튼을 결정할 수 있습니다.
"""
)
Expand All @@ -27,7 +27,7 @@ public class AdminImageGenerationStatusResponseDTO {
description = """
책 단위 이미지 생성 상태입니다.
EMPTY: 아직 후보사진이 없습니다. REFERENCE_GENERATING: 대표 후보사진 생성 중입니다.
REFERENCE_READY: 후보사진 4장 중 선택 대기 상태입니다. FANOUT_GENERATING: 선택된 대표 후보로 나머지 캐릭터 생성 중입니다.
REFERENCE_READY: 생성된 후보사진 중 선택 대기 상태입니다. FANOUT_GENERATING: 선택된 대표 후보로 나머지 캐릭터 생성 중입니다.
READY: 관리자 검토 가능한 상태입니다. FAILED: 후보 또는 캐릭터 이미지 중 실패 항목이 있습니다.
""",
allowableValues = {"EMPTY", "REFERENCE_GENERATING", "REFERENCE_READY", "FANOUT_GENERATING", "READY", "FAILED"},
Expand All @@ -45,16 +45,19 @@ public class AdminImageGenerationStatusResponseDTO {
)
private String nextAction;

@Schema(description = "가장 최근 대표 후보사진 생성 job입니다. status=READY 또는 FAILED가 되면 처리가 끝난 상태입니다.", nullable = true)
private ProcessingJobResponseDTO referenceCandidateJob;

@Schema(description = "가장 최근 캐릭터 이미지 fan-out job입니다. 대표 후보 선택 전에는 null입니다.", nullable = true)
private ProcessingJobResponseDTO fanoutJob;

@Schema(description = "서버가 자동 지정한 대표 캐릭터입니다. 후보사진 4장은 이 캐릭터 기준으로 생성됩니다.", nullable = true)
@Schema(description = "서버가 자동 지정한 대표 캐릭터입니다. 후보사진은 이 캐릭터 기준으로 생성됩니다.", nullable = true)
private CharacterSummary referenceCharacter;

@Schema(description = "관리자가 선택한 대표 후보사진 asset ID입니다. 선택 전에는 null입니다.", nullable = true, example = "102")
private Long selectedReferenceCandidateId;

@Schema(description = "책에 저장된 대표 캐릭터 후보사진 목록입니다. slotNo 1~4만 유지되며 재생성 시 같은 slot을 덮어씁니다.")
@Schema(description = "책에 저장된 대표 캐릭터 후보사진 목록입니다. 기본 slotNo는 1~2이며 재생성 시 같은 slot을 덮어씁니다.")
private List<ReferenceCandidate> referenceCandidates;

@Schema(description = "책에 등록된 캐릭터별 현재 이미지 생성 상태 목록입니다.")
Expand Down Expand Up @@ -96,7 +99,7 @@ public static class ReferenceCandidate {
@Schema(description = "후보사진 asset DB ID입니다. 후보 선택 API의 candidateId로 이 값을 전달합니다.", example = "101")
private Long id;

@Schema(description = "화면 표시용 후보 슬롯 번호입니다. 책마다 1~4만 유지되며 candidateId로 사용하지 않습니다.", example = "1")
@Schema(description = "화면 표시용 후보 슬롯 번호입니다. 기본값은 1~2이며 candidateId로 사용하지 않습니다.", example = "1")
private Integer slotNo;

@Schema(
Expand Down
132 changes: 23 additions & 109 deletions src/main/java/com/kw/readwith/service/AdminImageGenerationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,11 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

Expand All @@ -47,8 +43,6 @@
@Transactional(readOnly = true)
public class AdminImageGenerationService {

private static final int REFERENCE_CANDIDATE_COUNT = 4;

private final BookRepository bookRepository;
private final CharacterRepository characterRepository;
private final CharacterImageAssetRepository assetRepository;
Expand All @@ -57,6 +51,7 @@ public class AdminImageGenerationService {
private final OpenAiImageEditClient imageEditClient;
private final CharacterImageProperties imageProperties;
private final CharacterImageFanoutJobService fanoutJobService;
private final ReferenceCandidateJobService referenceCandidateJobService;
private final ProcessingJobRepository processingJobRepository;
private final RestTemplate restTemplate;
private final CdnUrlService cdnUrlService;
Expand All @@ -72,57 +67,15 @@ public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bo
ensureNoActiveFanoutJob(bookId);
Character referenceCharacter = resolveReferenceCharacter(book)
.orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_REFERENCE_CHARACTER_REQUIRED));
BookCharacterImageProfile profile = getOrCreateProfile(book);
Long previousActiveReferenceId = Optional.ofNullable(profile.getActiveReferenceAsset())
.map(CharacterImageAsset::getId)
.orElse(null);
profile.markReferenceCandidatesGenerating(
referenceCharacter,
resolveTextImageModel(),
sha256(normalize(imageProperties.getBaseStylePrompt())),
sha256(normalize(book.getBookPrompt()))
);

int successCount = 0;
for (int slotNo = 1; slotNo <= REFERENCE_CANDIDATE_COUNT; slotNo++) {
CharacterImageAsset asset = getOrCreateReferenceCandidate(book, referenceCharacter, slotNo);
try {
String previousS3Url = asset.getS3Url();
GeneratedCharacterImage generated = characterImageService.generateTextImage(referenceCharacter);
String s3Url = characterImageService.uploadGeneratedImage(
referenceCharacter,
generated.imageData(),
characterImageService.buildReferenceCandidateSlotS3KeyName(
referenceCharacter,
slotNo,
asset.getAttemptNo()
)
);
asset.generated(s3Url, generated.model(), generated.promptHash(), generated.requestId());
asset.markQaPassed("{\"passed\":true,\"mode\":\"ADMIN_REFERENCE_CANDIDATE\"}");
if (!Objects.equals(asset.getId(), previousActiveReferenceId)) {
characterImageService.deleteReplacedGeneratedImage(previousS3Url, s3Url);
}
successCount++;
} catch (Exception e) {
log.error("Reference candidate generation failed. bookId={}, slotNo={}", bookId, slotNo, e);
asset.fail("REFERENCE_GENERATION_FAILED");
}
}

if (successCount > 0) {
profile.markReferenceCandidatesReady(referenceCharacter);
} else {
profile.markQaFailed(referenceCharacter);
}

referenceCandidateJobService.queue(book, referenceCharacter);
return buildStatusResponse(book);
}

@Transactional
public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookId, Long candidateId) {
Book book = getBook(bookId);
ensureNoActiveFanoutJob(bookId);
referenceCandidateJobService.ensureNoActiveJob(bookId);
CharacterImageAsset candidate = assetRepository.findByIdAndBook(candidateId, book)
.orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_ASSET_NOT_BELONG_TO_BOOK));
ensureReferenceCandidate(candidate);
Expand All @@ -147,6 +100,7 @@ public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookI
public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookId, Long characterId) {
Book book = getBook(bookId);
ensureNoActiveFanoutJob(bookId);
referenceCandidateJobService.ensureNoActiveJob(bookId);
Character character = getCharacter(characterId);
if (!character.getBook().getId().equals(book.getId())) {
throw new GeneralException(ErrorStatus.BOOK_CHARACTER_NOT_FOUND);
Expand All @@ -158,7 +112,7 @@ public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookI
&& profile.getReferenceCharacter().getId().equals(character.getId())) {
throw new GeneralException(
ErrorStatus.IMAGE_ASSET_INVALID_STATUS,
"대표 캐릭터 이미지는 후보사진 4장을 재생성한 뒤 다시 선택해야 합니다."
"대표 캐릭터 이미지는 후보사진을 재생성한 뒤 다시 선택해야 합니다."
);
}

Expand Down Expand Up @@ -206,13 +160,20 @@ private CharacterImageAsset generateCharacterImageFromReference(Character charac

private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) {
Optional<BookCharacterImageProfile> profile = profileRepository.findByBook(book);
Long selectedReferenceCandidateId = profile
.map(BookCharacterImageProfile::getActiveReferenceAsset)
.map(CharacterImageAsset::getId)
.orElse(null);
int candidateCount = referenceCandidateCount();
List<Character> characters = characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book);
List<CharacterImageAsset> referenceCandidates = assetRepository
.findByBookAndAssetRoleOrderBySlotNoAscCreatedAtAsc(book, CharacterImageAssetRole.REFERENCE_CANDIDATE)
.stream()
.filter(asset -> asset.getSlotNo() != null
&& asset.getSlotNo() >= 1
&& asset.getSlotNo() <= REFERENCE_CANDIDATE_COUNT)
&& (asset.getSlotNo() <= candidateCount
|| (selectedReferenceCandidateId != null
&& selectedReferenceCandidateId.equals(asset.getId()))))
.sorted(Comparator.comparing(
CharacterImageAsset::getSlotNo,
Comparator.nullsLast(Integer::compareTo)
Expand All @@ -229,10 +190,6 @@ private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) {
LinkedHashMap::new
));

Long selectedReferenceCandidateId = profile
.map(BookCharacterImageProfile::getActiveReferenceAsset)
.map(CharacterImageAsset::getId)
.orElse(null);
Character referenceCharacter = profile
.map(BookCharacterImageProfile::getReferenceCharacter)
.or(() -> resolveReferenceCharacter(book))
Expand All @@ -247,11 +204,19 @@ private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) {
)
.map(ProcessingJobResponseDTO::from)
.orElse(null);
ProcessingJobResponseDTO referenceCandidateJob = processingJobRepository
.findTopByBookIdAndPipelineTypeOrderByCreatedAtDesc(
book.getId(),
ProcessingPipelineType.IMAGE_REFERENCE_GENERATION
)
.map(ProcessingJobResponseDTO::from)
.orElse(null);

return AdminImageGenerationStatusResponseDTO.builder()
.bookId(book.getId())
.status(status)
.nextAction(resolveNextAction(status))
.referenceCandidateJob(referenceCandidateJob)
.fanoutJob(fanoutJob)
.referenceCharacter(AdminImageGenerationStatusResponseDTO.CharacterSummary.from(referenceCharacter))
.selectedReferenceCandidateId(selectedReferenceCandidateId)
Expand Down Expand Up @@ -311,31 +276,6 @@ private String resolveNextAction(String status) {
};
}

private CharacterImageAsset getOrCreateReferenceCandidate(Book book,
Character referenceCharacter,
int slotNo) {
Optional<CharacterImageAsset> existing = assetRepository.findByBookAndAssetRoleAndSlotNo(
book,
CharacterImageAssetRole.REFERENCE_CANDIDATE,
slotNo
);
if (existing.isPresent()) {
CharacterImageAsset asset = existing.get();
asset.beginReferenceCandidate(referenceCharacter, slotNo);
return asset;
}

return assetRepository.save(CharacterImageAsset.builder()
.book(book)
.character(referenceCharacter)
.assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE)
.generationMode(CharacterImageGenerationMode.TEXT_TO_IMAGE)
.slotNo(slotNo)
.status(CharacterImageAssetStatus.GENERATING)
.attemptNo(1)
.build());
}

private CharacterImageAsset getOrCreateCharacterImageAsset(Character character,
CharacterImageAsset reference,
int referenceVersion) {
Expand Down Expand Up @@ -435,33 +375,7 @@ private boolean isReadyForSelection(CharacterImageAssetStatus status) {
|| status == CharacterImageAssetStatus.PUBLISHED;
}

private String resolveTextImageModel() {
String configured = normalize(imageProperties.getModel());
return configured != null ? configured : "gpt-image-2";
}

private String normalize(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}

private String sha256(String value) {
if (value == null) {
return null;
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (byte b : hash) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is not available.", e);
}
private int referenceCandidateCount() {
return Math.min(Math.max(imageProperties.getReferenceCandidateCount(), 1), 4);
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/kw/readwith/service/CharacterImageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ public String buildReferenceCandidateSlotS3KeyName(Character character, int slot
slotNo);
}

public String buildReferenceCandidateJobS3KeyName(Character character, String runId, int slotNo) {
return String.format("%s/%d/reference-jobs/%s/slot-%d.png",
imageProperties.getS3Path(),
character.getBook().getId(),
runId,
slotNo);
}

public String buildCandidateS3KeyName(Character character, Long assetId) {
return String.format("%s/%d/%d/candidates/%d-%s.png",
imageProperties.getS3Path(),
Expand Down Expand Up @@ -458,6 +466,18 @@ public void afterCommit() {
cleanup.run();
}

public void deleteGeneratedImage(String imageUrl) {
Optional<String> objectKey = cdnUrlService.toPublicObjectKey(imageUrl);
if (objectKey.isEmpty() || !isManagedCharacterImageKey(objectKey.get())) {
return;
}
try {
s3Manager.deleteKeys(List.of(objectKey.get()));
} catch (RuntimeException e) {
log.warn("Failed to delete unused character image from S3. key={}", objectKey.get(), e);
}
}

private boolean isManagedCharacterImageKey(String key) {
String configuredPath = normalizePromptSegment(imageProperties.getS3Path());
String imagePath = configuredPath == null ? "character-images" : configuredPath;
Expand Down
Loading
Loading