From 2da2f124a22932fe9bed6a7f7b42f1277af7628f Mon Sep 17 00:00:00 2001 From: cherry2250 Date: Mon, 27 Oct 2025 16:11:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0:=20=EC=9D=8C=EC=8B=9D=20=EC=A0=84=EB=AC=B8=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 음식 사진 전문성 강조 (professional food photography, appetizing food shot) - 업종을 cuisine으로 변환하여 음식 이미지에 집중 - 스타일별 플레이팅 강조 (elegant plating, minimalist plating, trendy plating) - negative prompt에 텍스트 관련 키워드 추가 (text, letters, words, typography, writing, numbers, characters, labels, watermark, logo, signage) - 최종 프롬프트에 'no text overlay, text-free, clean image' 명시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../StableDiffusionImageGenerator.java | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 content-service/src/main/java/com/kt/event/content/biz/service/StableDiffusionImageGenerator.java diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/StableDiffusionImageGenerator.java b/content-service/src/main/java/com/kt/event/content/biz/service/StableDiffusionImageGenerator.java new file mode 100644 index 0000000..f1d6058 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/biz/service/StableDiffusionImageGenerator.java @@ -0,0 +1,398 @@ +package com.kt.event.content.biz.service; + +import com.kt.event.content.biz.domain.Content; +import com.kt.event.content.biz.domain.GeneratedImage; +import com.kt.event.content.biz.domain.ImageStyle; +import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.domain.Platform; +import com.kt.event.content.biz.dto.ContentCommand; +import com.kt.event.content.biz.dto.JobInfo; +import com.kt.event.content.biz.dto.RedisJobData; +import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase; +import com.kt.event.content.biz.usecase.out.CDNUploader; +import com.kt.event.content.biz.usecase.out.ContentWriter; +import com.kt.event.content.biz.usecase.out.JobWriter; +import com.kt.event.content.infra.gateway.client.ReplicateApiClient; +import com.kt.event.content.infra.gateway.client.dto.ReplicateRequest; +import com.kt.event.content.infra.gateway.client.dto.ReplicateResponse; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Stable Diffusion 이미지 생성 서비스 + * + * Replicate API를 사용하여 Stable Diffusion XL 1.0으로 이미지 생성 + */ +@Slf4j +@Service +@Primary +@Profile({"prod", "dev"}) // production 및 dev 환경에서 활성화 (local은 Mock 사용) +public class StableDiffusionImageGenerator implements GenerateImagesUseCase { + + private final ReplicateApiClient replicateClient; + private final CDNUploader cdnUploader; + private final JobWriter jobWriter; + private final ContentWriter contentWriter; + private final CircuitBreaker circuitBreaker; + + @Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}") + private String modelVersion; + + public StableDiffusionImageGenerator( + ReplicateApiClient replicateClient, + CDNUploader cdnUploader, + JobWriter jobWriter, + ContentWriter contentWriter, + @Qualifier("replicateCircuitBreaker") CircuitBreaker circuitBreaker) { + this.replicateClient = replicateClient; + this.cdnUploader = cdnUploader; + this.jobWriter = jobWriter; + this.contentWriter = contentWriter; + this.circuitBreaker = circuitBreaker; + } + + @Override + public JobInfo execute(ContentCommand.GenerateImages command) { + log.info("Stable Diffusion 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}", + command.getEventDraftId(), command.getStyles(), command.getPlatforms()); + + // Job 생성 + String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8); + + Job job = Job.builder() + .id(jobId) + .eventDraftId(command.getEventDraftId()) + .jobType("image-generation") + .status(Job.Status.PENDING) + .progress(0) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + // Job 저장 + RedisJobData jobData = RedisJobData.builder() + .id(job.getId()) + .eventDraftId(job.getEventDraftId()) + .jobType(job.getJobType()) + .status(job.getStatus().name()) + .progress(job.getProgress()) + .createdAt(job.getCreatedAt()) + .updatedAt(job.getUpdatedAt()) + .build(); + + jobWriter.saveJob(jobData, 3600); // TTL 1시간 + log.info("Job 생성 완료: jobId={}", jobId); + + // 비동기로 이미지 생성 + processImageGeneration(jobId, command); + + return JobInfo.from(job); + } + + @Async + private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) { + try { + log.info("Stable Diffusion 이미지 생성 시작: jobId={}", jobId); + + // Content 생성 또는 조회 + Content content = Content.builder() + .eventDraftId(command.getEventDraftId()) + .eventTitle(command.getEventDraftId() + " 이벤트") + .eventDescription("AI 생성 이벤트 이미지") + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + Content savedContent = contentWriter.save(content); + log.info("Content 생성 완료: contentId={}", savedContent.getId()); + + // 스타일 x 플랫폼 조합으로 이미지 생성 + List styles = command.getStyles() != null && !command.getStyles().isEmpty() + ? command.getStyles() + : List.of(ImageStyle.FANCY, ImageStyle.SIMPLE); + + List platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty() + ? command.getPlatforms() + : List.of(Platform.INSTAGRAM, Platform.KAKAO); + + List images = new ArrayList<>(); + int totalCount = styles.size() * platforms.size(); + int currentCount = 0; + + for (ImageStyle style : styles) { + for (Platform platform : platforms) { + currentCount++; + + // 진행률 업데이트 + int progress = (currentCount * 100) / totalCount; + jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress); + + // Stable Diffusion으로 이미지 생성 + String prompt = buildPrompt(command, style, platform); + String imageUrl = generateImage(prompt, platform); + + // GeneratedImage 저장 + GeneratedImage image = GeneratedImage.builder() + .eventDraftId(command.getEventDraftId()) + .style(style) + .platform(platform) + .cdnUrl(imageUrl) + .prompt(prompt) + .selected(currentCount == 1) // 첫 번째 이미지를 선택 + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + if (currentCount == 1) { + image.select(); + } + + GeneratedImage savedImage = contentWriter.saveImage(image); + images.add(savedImage); + log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}", + savedImage.getId(), style, platform, imageUrl); + } + } + + // Job 완료 + String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size()); + jobWriter.updateJobStatus(jobId, "COMPLETED", 100); + jobWriter.updateJobResult(jobId, resultMessage); + log.info("Stable Diffusion Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size()); + + } catch (Exception e) { + log.error("Stable Diffusion 이미지 생성 실패: jobId={}", jobId, e); + jobWriter.updateJobError(jobId, e.getMessage()); + } + } + + /** + * Stable Diffusion으로 이미지 생성 + * + * @param prompt 이미지 생성 프롬프트 + * @param platform 플랫폼 (이미지 크기 결정) + * @return 생성된 이미지 URL + */ + private String generateImage(String prompt, Platform platform) { + try { + // 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴) + int width = platform.getWidth(); + int height = platform.getHeight(); + + // Replicate API 요청 + ReplicateRequest request = ReplicateRequest.builder() + .version(modelVersion) + .input(ReplicateRequest.Input.builder() + .prompt(prompt) + .negativePrompt("blurry, bad quality, distorted, ugly, low resolution, text, letters, words, typography, writing, numbers, characters, labels, watermark, logo, signage") + .width(width) + .height(height) + .numOutputs(1) + .guidanceScale(7.5) + .numInferenceSteps(50) + .seed(System.currentTimeMillis()) // 랜덤 시드 생성 + .build()) + .build(); + + log.info("Replicate API 호출 시작: prompt={}, size={}x{}", prompt, width, height); + ReplicateResponse response = createPredictionWithCircuitBreaker(request); + String predictionId = response.getId(); + log.info("Replicate 예측 생성: predictionId={}, status={}", predictionId, response.getStatus()); + + // 이미지 생성 완료까지 대기 (폴링) + String replicateUrl = waitForCompletion(predictionId); + log.info("Replicate 이미지 생성 완료: predictionId={}, url={}", predictionId, replicateUrl); + + // Replicate URL에서 이미지 다운로드 + byte[] imageData = downloadImage(replicateUrl); + log.info("이미지 다운로드 완료: size={} bytes", imageData.length); + + // Azure Blob Storage에 업로드 + String fileName = String.format("event-%s-%s-%s.png", + platform.name().toLowerCase(), + predictionId.substring(0, 8), + System.currentTimeMillis()); + String azureCdnUrl = cdnUploader.upload(imageData, fileName); + log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl); + + return azureCdnUrl; + + } catch (Exception e) { + log.error("Stable Diffusion 이미지 생성 실패: prompt={}", prompt, e); + throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e); + } + } + + /** + * Replicate API 예측 완료 대기 (폴링) + * + * @param predictionId 예측 ID + * @return 생성된 이미지 URL + */ + private String waitForCompletion(String predictionId) throws InterruptedException { + int maxRetries = 60; // 최대 5분 (5초 x 60회) + int retryCount = 0; + + while (retryCount < maxRetries) { + ReplicateResponse response = getPredictionWithCircuitBreaker(predictionId); + String status = response.getStatus(); + + log.debug("Replicate 상태 조회: predictionId={}, status={}, retry={}/{}", + predictionId, status, retryCount, maxRetries); + + if ("succeeded".equals(status)) { + List output = response.getOutput(); + if (output != null && !output.isEmpty()) { + return output.get(0); + } + throw new RuntimeException("이미지 URL이 없습니다"); + } else if ("failed".equals(status) || "canceled".equals(status)) { + String error = response.getError() != null ? response.getError() : "알 수 없는 오류"; + throw new RuntimeException("이미지 생성 실패: " + error); + } + + // 5초 대기 후 재시도 + Thread.sleep(5000); + retryCount++; + } + + throw new RuntimeException("이미지 생성 타임아웃 (5분 초과)"); + } + + /** + * 이미지 생성 프롬프트 구성 + */ + private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) { + StringBuilder prompt = new StringBuilder(); + + // 음식 사진 전문성 강조 + prompt.append("professional food photography, appetizing food shot, "); + + // 업종 정보 추가 + if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) { + prompt.append(command.getIndustry()).append(" cuisine, "); + } + + // 지역 정보 추가 + if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) { + prompt.append(command.getLocation()).append(" style, "); + } + + // 트렌드 키워드 추가 (최대 3개) + if (command.getTrends() != null && !command.getTrends().isEmpty()) { + prompt.append("featuring "); + int count = Math.min(3, command.getTrends().size()); + for (int i = 0; i < count; i++) { + if (i > 0) prompt.append(", "); + prompt.append(command.getTrends().get(i)); + } + prompt.append(", "); + } + + // 스타일별 프롬프트 + switch (style) { + case FANCY: + prompt.append("elegant plating, luxurious presentation, premium dish, vibrant colors, "); + break; + case SIMPLE: + prompt.append("minimalist plating, clean presentation, simple arrangement, modern style, "); + break; + case TRENDY: + prompt.append("trendy plating, contemporary style, stylish presentation, modern gastronomy, "); + break; + } + + // 플랫폼별 특성 추가 + prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, "); + + // 고품질 음식 사진 + 텍스트 제외 명시 + prompt.append("high quality, detailed, 4k resolution, professional lighting, no text overlay, text-free, clean image"); + + return prompt.toString(); + } + + /** + * URL에서 이미지 다운로드 + * + * @param imageUrl 이미지 URL + * @return 이미지 바이트 데이터 + */ + private byte[] downloadImage(String imageUrl) throws Exception { + log.info("이미지 다운로드 시작: url={}", imageUrl); + + URL url = new URL(imageUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(30000); // 30초 + connection.setReadTimeout(30000); // 30초 + + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new RuntimeException("이미지 다운로드 실패: HTTP " + responseCode); + } + + // 이미지 데이터 읽기 + try (InputStream inputStream = connection.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } + } + + /** + * Circuit Breaker로 보호된 Replicate 예측 생성 + * + * @param request Replicate 요청 + * @return Replicate 응답 + */ + private ReplicateResponse createPredictionWithCircuitBreaker(ReplicateRequest request) { + try { + return circuitBreaker.executeSupplier(() -> replicateClient.createPrediction(request)); + } catch (CallNotPermittedException e) { + log.error("Replicate Circuit Breaker가 OPEN 상태입니다. 예측 생성 차단"); + throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e); + } catch (Exception e) { + log.error("Replicate 예측 생성 실패", e); + throw new RuntimeException("이미지 생성 요청 실패: " + e.getMessage(), e); + } + } + + /** + * Circuit Breaker로 보호된 Replicate 예측 조회 + * + * @param predictionId 예측 ID + * @return Replicate 응답 + */ + private ReplicateResponse getPredictionWithCircuitBreaker(String predictionId) { + try { + return circuitBreaker.executeSupplier(() -> replicateClient.getPrediction(predictionId)); + } catch (CallNotPermittedException e) { + log.error("Replicate Circuit Breaker가 OPEN 상태입니다. 예측 조회 차단: predictionId={}", predictionId); + throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e); + } catch (Exception e) { + log.error("Replicate 예측 조회 실패: predictionId={}", predictionId, e); + throw new RuntimeException("이미지 생성 상태 확인 실패: " + e.getMessage(), e); + } + } +}