캐릭터 챗봇 #338
| @@ -35,7 +35,10 @@ class AdminCharacterImageController( | |||||||
|     private val imageCloudFront: ImageContentCloudFront, |     private val imageCloudFront: ImageContentCloudFront, | ||||||
|  |  | ||||||
|     @Value("\${cloud.aws.s3.content-bucket}") |     @Value("\${cloud.aws.s3.content-bucket}") | ||||||
|     private val s3Bucket: String |     private val s3Bucket: String, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.s3.bucket}") | ||||||
|  |     private val freeBucket: String | ||||||
| ) { | ) { | ||||||
|  |  | ||||||
|     @GetMapping("/list") |     @GetMapping("/list") | ||||||
| @@ -65,13 +68,19 @@ class AdminCharacterImageController( | |||||||
|         val objectMapper = ObjectMapper() |         val objectMapper = ObjectMapper() | ||||||
|         val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) |         val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) | ||||||
|  |  | ||||||
|         // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 |         // 업로드 키 생성 | ||||||
|         val tempKey = buildS3Key(characterId = request.characterId) |         val s3Key = buildS3Key(characterId = request.characterId) | ||||||
|         val imagePath = saveImage(tempKey, image) |  | ||||||
|  |         // 원본 저장 (content-bucket) | ||||||
|  |         val imagePath = saveImageToBucket(s3Key, image, s3Bucket) | ||||||
|  |  | ||||||
|  |         // 블러 생성 및 저장 (무료 이미지 버킷) | ||||||
|  |         val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket) | ||||||
|  |  | ||||||
|         imageService.registerImage( |         imageService.registerImage( | ||||||
|             characterId = request.characterId, |             characterId = request.characterId, | ||||||
|             imagePath = imagePath, |             imagePath = imagePath, | ||||||
|  |             blurImagePath = blurImagePath, | ||||||
|             price = request.price, |             price = request.price, | ||||||
|             isAdult = request.isAdult, |             isAdult = request.isAdult, | ||||||
|             triggers = request.triggers ?: emptyList() |             triggers = request.triggers ?: emptyList() | ||||||
| @@ -108,13 +117,13 @@ class AdminCharacterImageController( | |||||||
|         return "characters/$characterId/images/$fileName" |         return "characters/$characterId/images/$fileName" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun saveImage(filePath: String, image: MultipartFile): String { |     private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String { | ||||||
|         try { |         try { | ||||||
|             val metadata = ObjectMetadata() |             val metadata = ObjectMetadata() | ||||||
|             metadata.contentLength = image.size |             metadata.contentLength = image.size | ||||||
|             return s3Uploader.upload( |             return s3Uploader.upload( | ||||||
|                 inputStream = image.inputStream, |                 inputStream = image.inputStream, | ||||||
|                 bucket = s3Bucket, |                 bucket = bucket, | ||||||
|                 filePath = filePath, |                 filePath = filePath, | ||||||
|                 metadata = metadata |                 metadata = metadata | ||||||
|             ) |             ) | ||||||
| @@ -122,4 +131,36 @@ class AdminCharacterImageController( | |||||||
|             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") |             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String { | ||||||
|  |         try { | ||||||
|  |             // 멀티파트를 BufferedImage로 읽기 | ||||||
|  |             val bytes = image.bytes | ||||||
|  |             val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) | ||||||
|  |                 ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") | ||||||
|  |             val blurred = kr.co.vividnext.sodalive.utils.ImageBlurUtil.blur(bimg, 12) | ||||||
|  |  | ||||||
|  |             // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 | ||||||
|  |             val baos = java.io.ByteArrayOutputStream() | ||||||
|  |             val format = when (image.contentType?.lowercase()) { | ||||||
|  |                 "image/png" -> "png" | ||||||
|  |                 else -> "jpg" | ||||||
|  |             } | ||||||
|  |             javax.imageio.ImageIO.write(blurred, format, baos) | ||||||
|  |             val inputStream = java.io.ByteArrayInputStream(baos.toByteArray()) | ||||||
|  |  | ||||||
|  |             val metadata = ObjectMetadata() | ||||||
|  |             metadata.contentLength = baos.size().toLong() | ||||||
|  |             metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg" | ||||||
|  |  | ||||||
|  |             return s3Uploader.upload( | ||||||
|  |                 inputStream = inputStream, | ||||||
|  |                 bucket = bucket, | ||||||
|  |                 filePath = filePath, | ||||||
|  |                 metadata = metadata | ||||||
|  |             ) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}") | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -15,9 +15,12 @@ class CharacterImage( | |||||||
|     @JoinColumn(name = "character_id") |     @JoinColumn(name = "character_id") | ||||||
|     var chatCharacter: ChatCharacter, |     var chatCharacter: ChatCharacter, | ||||||
|  |  | ||||||
|     // 이미지 경로 (S3 key) |     // 원본 이미지 경로 (S3 key - content-bucket) | ||||||
|     var imagePath: String, |     var imagePath: String, | ||||||
|  |  | ||||||
|  |     // 블러 이미지 경로 (S3 key - free/public bucket) | ||||||
|  |     var blurImagePath: String, | ||||||
|  |  | ||||||
|     // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) |     // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) | ||||||
|     var price: Long = 0L, |     var price: Long = 0L, | ||||||
|  |  | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ class CharacterImageService( | |||||||
|     fun registerImage( |     fun registerImage( | ||||||
|         characterId: Long, |         characterId: Long, | ||||||
|         imagePath: String, |         imagePath: String, | ||||||
|  |         blurImagePath: String, | ||||||
|         price: Long, |         price: Long, | ||||||
|         isAdult: Boolean, |         isAdult: Boolean, | ||||||
|         triggers: List<String> |         triggers: List<String> | ||||||
| @@ -36,6 +37,7 @@ class CharacterImageService( | |||||||
|         val entity = CharacterImage( |         val entity = CharacterImage( | ||||||
|             chatCharacter = character, |             chatCharacter = character, | ||||||
|             imagePath = imagePath, |             imagePath = imagePath, | ||||||
|  |             blurImagePath = blurImagePath, | ||||||
|             price = price, |             price = price, | ||||||
|             isAdult = isAdult, |             isAdult = isAdult, | ||||||
|             sortOrder = nextOrder, |             sortOrder = nextOrder, | ||||||
|   | |||||||
							
								
								
									
										102
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | |||||||
|  | package kr.co.vividnext.sodalive.utils | ||||||
|  |  | ||||||
|  | import java.awt.image.BufferedImage | ||||||
|  | import kotlin.math.exp | ||||||
|  | import kotlin.math.max | ||||||
|  | import kotlin.math.min | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 가우시안 커널 기반 블러 유틸리티 | ||||||
|  |  * - 반경(radius)에 따라 커널 크기(2*radius+1) 생성 | ||||||
|  |  * - 시그마는 관례적으로 radius/3.0 적용 | ||||||
|  |  * - 수평/수직 분리 합성곱으로 품질과 성능 확보 | ||||||
|  |  */ | ||||||
|  | object ImageBlurUtil { | ||||||
|  |     fun blur(src: BufferedImage, radius: Int = 10): BufferedImage { | ||||||
|  |         require(radius > 0) { "radius must be > 0" } | ||||||
|  |         val w = src.width | ||||||
|  |         val h = src.height | ||||||
|  |         val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) | ||||||
|  |  | ||||||
|  |         // 가우시안 1D 커널 생성 및 정규화 | ||||||
|  |         val sigma = radius / 3.0 | ||||||
|  |         val kernel = gaussianKernel(radius, sigma) | ||||||
|  |  | ||||||
|  |         // 중간 버퍼 | ||||||
|  |         val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) | ||||||
|  |  | ||||||
|  |         // 수평 합성곱 | ||||||
|  |         for (y in 0 until h) { | ||||||
|  |             for (x in 0 until w) { | ||||||
|  |                 var aAcc = 0.0 | ||||||
|  |                 var rAcc = 0.0 | ||||||
|  |                 var gAcc = 0.0 | ||||||
|  |                 var bAcc = 0.0 | ||||||
|  |                 for (k in -radius..radius) { | ||||||
|  |                     val xx = clamp(x + k, 0, w - 1) | ||||||
|  |                     val rgb = src.getRGB(xx, y) | ||||||
|  |                     val a = (rgb ushr 24) and 0xFF | ||||||
|  |                     val r = (rgb ushr 16) and 0xFF | ||||||
|  |                     val g = (rgb ushr 8) and 0xFF | ||||||
|  |                     val b = rgb and 0xFF | ||||||
|  |                     val wgt = kernel[k + radius] | ||||||
|  |                     aAcc += a * wgt | ||||||
|  |                     rAcc += r * wgt | ||||||
|  |                     gAcc += g * wgt | ||||||
|  |                     bAcc += b * wgt | ||||||
|  |                 } | ||||||
|  |                 val a = aAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val r = rAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val g = gAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val b = bAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 temp.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 수직 합성곱 | ||||||
|  |         for (x in 0 until w) { | ||||||
|  |             for (y in 0 until h) { | ||||||
|  |                 var aAcc = 0.0 | ||||||
|  |                 var rAcc = 0.0 | ||||||
|  |                 var gAcc = 0.0 | ||||||
|  |                 var bAcc = 0.0 | ||||||
|  |                 for (k in -radius..radius) { | ||||||
|  |                     val yy = clamp(y + k, 0, h - 1) | ||||||
|  |                     val rgb = temp.getRGB(x, yy) | ||||||
|  |                     val a = (rgb ushr 24) and 0xFF | ||||||
|  |                     val r = (rgb ushr 16) and 0xFF | ||||||
|  |                     val g = (rgb ushr 8) and 0xFF | ||||||
|  |                     val b = rgb and 0xFF | ||||||
|  |                     val wgt = kernel[k + radius] | ||||||
|  |                     aAcc += a * wgt | ||||||
|  |                     rAcc += r * wgt | ||||||
|  |                     gAcc += g * wgt | ||||||
|  |                     bAcc += b * wgt | ||||||
|  |                 } | ||||||
|  |                 val a = aAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val r = rAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val g = gAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 val b = bAcc.toInt().coerceIn(0, 255) | ||||||
|  |                 dst.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return dst | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray { | ||||||
|  |         val size = 2 * radius + 1 | ||||||
|  |         val kernel = DoubleArray(size) | ||||||
|  |         val sigma2 = 2.0 * sigma * sigma | ||||||
|  |         var sum = 0.0 | ||||||
|  |         for (i in -radius..radius) { | ||||||
|  |             val v = exp(-(i * i) / sigma2) | ||||||
|  |             kernel[i + radius] = v | ||||||
|  |             sum += v | ||||||
|  |         } | ||||||
|  |         // 정규화 | ||||||
|  |         for (i in kernel.indices) kernel[i] /= sum | ||||||
|  |         return kernel | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user