이미지 생성 프롬프트 개선: 음식 전문 사진 생성 및 텍스트 제외
- 음식 사진 전문성 강조 (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:
parent
c82dbc6572
commit
2da2f124a2
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user