From 2a30b28e43f8d6657be1ce68a5cf165bef237e26 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 04:00:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록시 블러 이미지를 생성하여 저장하는 기능 추가 --- .../image/AdminCharacterImageController.kt | 53 +++++++-- .../chat/character/image/CharacterImage.kt | 5 +- .../character/image/CharacterImageService.kt | 2 + .../vividnext/sodalive/utils/ImageBlurUtil.kt | 102 ++++++++++++++++++ 4 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 2c181c3..76503c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -35,7 +35,10 @@ class AdminCharacterImageController( private val imageCloudFront: ImageContentCloudFront, @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") @@ -65,13 +68,19 @@ class AdminCharacterImageController( val objectMapper = ObjectMapper() val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) - // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 - val tempKey = buildS3Key(characterId = request.characterId) - val imagePath = saveImage(tempKey, image) + // 업로드 키 생성 + val s3Key = buildS3Key(characterId = request.characterId) + + // 원본 저장 (content-bucket) + val imagePath = saveImageToBucket(s3Key, image, s3Bucket) + + // 블러 생성 및 저장 (무료 이미지 버킷) + val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket) imageService.registerImage( characterId = request.characterId, imagePath = imagePath, + blurImagePath = blurImagePath, price = request.price, isAdult = request.isAdult, triggers = request.triggers ?: emptyList() @@ -108,13 +117,13 @@ class AdminCharacterImageController( return "characters/$characterId/images/$fileName" } - private fun saveImage(filePath: String, image: MultipartFile): String { + private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String { try { val metadata = ObjectMetadata() metadata.contentLength = image.size return s3Uploader.upload( inputStream = image.inputStream, - bucket = s3Bucket, + bucket = bucket, filePath = filePath, metadata = metadata ) @@ -122,4 +131,36 @@ class AdminCharacterImageController( 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}") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt index 350c7a2..e5aaa0b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt @@ -15,9 +15,12 @@ class CharacterImage( @JoinColumn(name = "character_id") var chatCharacter: ChatCharacter, - // 이미지 경로 (S3 key) + // 원본 이미지 경로 (S3 key - content-bucket) var imagePath: String, + // 블러 이미지 경로 (S3 key - free/public bucket) + var blurImagePath: String, + // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) var price: Long = 0L, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index 6cf8768..cceb7f3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -23,6 +23,7 @@ class CharacterImageService( fun registerImage( characterId: Long, imagePath: String, + blurImagePath: String, price: Long, isAdult: Boolean, triggers: List @@ -36,6 +37,7 @@ class CharacterImageService( val entity = CharacterImage( chatCharacter = character, imagePath = imagePath, + blurImagePath = blurImagePath, price = price, isAdult = isAdult, sortOrder = nextOrder, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt new file mode 100644 index 0000000..6b1d066 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -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)) +}