이미지 생성 프롬프트 개선: 음식 전문 사진 생성 및 텍스트 제외

- 음식 사진 전문성 강조 (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 <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-27 16:11:31 +09:00
parent c82dbc6572
commit 2da2f124a2

View File

@ -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<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> 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<String> 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);
}
}
}