mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 06:46:25 +00:00
이미지 생성 프롬프트 개선: 음식 전문 사진 생성 및 텍스트 제외
- 음식 사진 전문성 강조 (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