feat(chat-character-image): 캐릭터 이미지
- 등록시 블러 이미지를 생성하여 저장하는 기능 추가
This commit is contained in:
parent
dd6849b840
commit
2a30b28e43
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
Loading…
Reference in New Issue